diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh
index 6de638b0..4779be88 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 move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
+SUITES=(block doc render api meta page page-full markdown runs text section compose tree-edit move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
OUT_JSON="lib/content/scoreboard.json"
OUT_MD="lib/content/scoreboard.md"
@@ -69,6 +69,7 @@ run_suite() {
(load "lib/content/page.sx")
(load "lib/content/page-full.sx")
(load "lib/content/markdown.sx")
+(load "lib/content/runs.sx")
(load "lib/content/validate.sx")
(load "lib/content/sanitize.sx")
(load "lib/content/store.sx")
diff --git a/lib/content/find-replace.sx b/lib/content/find-replace.sx
index e6ee76b7..18011355 100644
--- a/lib/content/find-replace.sx
+++ b/lib/content/find-replace.sx
@@ -10,6 +10,11 @@
;; via content/find-replace and a word count over asText stay consistent.
;; Immutable; case-sensitive.
;;
+;; A text field may be a plain string OR a list of rich-text runs (Phase 5,
+;; run = (text marks href)). fr-rep-text rewrites per run, preserving each run's
+;; marks/href; a match that physically straddles two runs is not joined (the
+;; replacement would have no single mark set) — each run is rewritten in place.
+;;
;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks),
;; table.sx (CtTable ivars).
@@ -24,6 +29,23 @@
(define fr-rep (fn (s from to) (replace (str s) from to)))
+;; rewrite a text-bearing field that is either a plain string or a runs list
+(define
+ fr-rep-text
+ (fn
+ (v from to)
+ (if
+ (list? v)
+ (map
+ (fn
+ (r)
+ (list
+ (fr-rep (nth r 0) from to)
+ (nth r 1)
+ (nth r 2)))
+ v)
+ (fr-rep v from to))))
+
;; Blocks whose prose content find/replace rewrites (matches asText's set).
(define
fr-has-text?
@@ -66,7 +88,7 @@
(if (list? r) (map (fn (c) (fr-rep c from to)) r) r))
rs))
b1))))
- (else (blk-set b "text" (fr-rep (blk-get b "text") from to)))))))
+ (else (blk-set b "text" (fr-rep-text (blk-get b "text") from to)))))))
(define
content/find-replace
diff --git a/lib/content/runs.sx b/lib/content/runs.sx
new file mode 100644
index 00000000..3b0d32da
--- /dev/null
+++ b/lib/content/runs.sx
@@ -0,0 +1,118 @@
+;; content-on-sx — Phase 5: rich inline text (structured runs).
+;;
+;; A CtText's `text` ivar may be EITHER a plain string (backward compat) OR a
+;; list of inline RUNS. A run is a 3-element list (text marks href):
+;; text — a string
+;; marks — a list of mark tokens, a subset of
+;; :bold :italic :underline :strikethrough :code :subscript
+;; :superscript :link (SX keywords evaluate to the strings the
+;; Smalltalk renderer compares against; build them with keywords)
+;; href — a string ("" when absent; the link target for a :link mark)
+;;
+;; Runs are a LIST, not a {:text :marks} dict, because rendering happens inside
+;; the Smalltalk render methods (nested blocks dispatch asHTML/etc. via Smalltalk
+;; message sends) and the Smalltalk-on-SX layer can iterate SX lists but cannot
+;; read SX dict fields. Lists are Smalltalk-native, render under nesting, and
+;; round-trip through data/wire for free.
+;;
+;; content-bootstrap-runs! OVERRIDES the render/markdown/text methods of CtText
+;; and its subclasses (CtHeading/CtQuote rich; CtCode verbatim — runs render as
+;; plain concatenated text) with run-aware versions that produce IDENTICAL output
+;; for a plain-string body. Opt-in: call after the render/markdown/text
+;; bootstraps; suites that don't call it are unaffected.
+;;
+;; Requires (loaded by harness): block.sx, render.sx, markdown.sx, text.sx.
+
+;; ── SX-side run helpers ──
+(define mk-run (fn (text marks href) (list text marks href)))
+(define mk-run-plain (fn (text) (list text (list) "")))
+(define run-text (fn (r) (nth r 0)))
+(define run-marks (fn (r) (nth r 1)))
+(define run-href (fn (r) (nth r 2)))
+;; a CtText body is "rich" iff it is a runs list (vs a plain string)
+(define runs? (fn (v) (list? v)))
+;; build a CtText whose body is a list of runs
+(define
+ mk-rich-text
+ (fn (id runs) (st-iv-set! (mk-text id "") "text" runs)))
+
+(define
+ content-bootstrap-runs!
+ (fn
+ ()
+ (begin
+ (ct-def-method!
+ "CtText"
+ "runHtml:"
+ "runHtml: run | frag marks href | frag := (run at: 1) htmlEscaped. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '' , frag , '']. (m = 'italic') ifTrue: [frag := '' , frag , '']. (m = 'underline') ifTrue: [frag := '' , frag , '']. (m = 'strikethrough') ifTrue: [frag := '' , frag , '']. (m = 'code') ifTrue: [frag := '' , frag , '']. (m = 'subscript') ifTrue: [frag := '' , frag , '']. (m = 'superscript') ifTrue: [frag := '' , frag , '']. (m = 'link') ifTrue: [frag := '' , frag , '']]. ^ frag")
+ (ct-def-method!
+ "CtText"
+ "runSx:"
+ "runSx: run | frag marks href | frag := '\"' , (run at: 1) sxEscaped , '\"'. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '(strong ' , frag , ')']. (m = 'italic') ifTrue: [frag := '(em ' , frag , ')']. (m = 'underline') ifTrue: [frag := '(u ' , frag , ')']. (m = 'strikethrough') ifTrue: [frag := '(s ' , frag , ')']. (m = 'code') ifTrue: [frag := '(code ' , frag , ')']. (m = 'subscript') ifTrue: [frag := '(sub ' , frag , ')']. (m = 'superscript') ifTrue: [frag := '(sup ' , frag , ')']. (m = 'link') ifTrue: [frag := '(a :href \"' , href sxEscaped , '\" ' , frag , ')']]. ^ frag")
+ (ct-def-method!
+ "CtText"
+ "runMd:"
+ "runMd: run | frag marks href | frag := (run at: 1). marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '**' , frag , '**']. (m = 'italic') ifTrue: [frag := '_' , frag , '_']. (m = 'strikethrough') ifTrue: [frag := '~~' , frag , '~~']. (m = 'code') ifTrue: [frag := '`' , frag , '`']. (m = 'underline') ifTrue: [frag := '' , frag , '']. (m = 'subscript') ifTrue: [frag := '' , frag , '']. (m = 'superscript') ifTrue: [frag := '' , frag , '']. (m = 'link') ifTrue: [frag := '[' , frag , '](' , href , ')']]. ^ frag")
+ (ct-def-method!
+ "CtText"
+ "inlineHtml"
+ "inlineHtml | out | (text class name = 'String') ifTrue: [^ text htmlEscaped]. out := ''. text do: [:run | out := out , (self runHtml: run)]. ^ out")
+ (ct-def-method!
+ "CtText"
+ "inlineSx"
+ "inlineSx | out | (text class name = 'String') ifTrue: [^ '\"' , text sxEscaped , '\"']. out := ''. text do: [:run | out := (out = '' ifTrue: [self runSx: run] ifFalse: [out , ' ' , (self runSx: run)])]. ^ out")
+ (ct-def-method!
+ "CtText"
+ "inlineMd"
+ "inlineMd | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (self runMd: run)]. ^ out")
+ (ct-def-method!
+ "CtText"
+ "inlineText"
+ "inlineText | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (run at: 1)]. ^ out")
+ (ct-def-method!
+ "CtText"
+ "asHTML"
+ "asHTML ^ '
' , self inlineHtml , '
'") + (ct-def-method! "CtText" "asSx" "asSx ^ '(p ' , self inlineSx , ')'") + (ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ self inlineMd") + (ct-def-method! "CtText" "asText" "asText ^ self inlineText") + (ct-def-method! + "CtHeading" + "asHTML" + "asHTML | t | t := level printString. ^ '' , self inlineHtml , ''") + (ct-def-method! + "CtQuote" + "asSx" + "asSx ^ '(blockquote ' , self inlineSx , ')'") + (ct-def-method! + "CtQuote" + "asMarkdown:" + "asMarkdown: nl ^ '> ' , self inlineMd") + (ct-def-method! "CtQuote" "asText" "asText ^ self inlineText") + (ct-def-method! + "CtCode" + "asHTML" + "asHTML ^ '
' , self inlineText htmlEscaped , ''")
+ (ct-def-method!
+ "CtCode"
+ "asSx"
+ "asSx ^ '(pre (code \"' , self inlineText sxEscaped , '\"))'")
+ (ct-def-method!
+ "CtCode"
+ "asMarkdown:"
+ "asMarkdown: nl ^ '```' , language , nl , self inlineText , nl , '```'")
+ (ct-def-method! "CtCode" "asText" "asText ^ self inlineText")
+ true)))
diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json
index a09ac9b1..c234e66f 100644
--- a/lib/content/scoreboard.json
+++ b/lib/content/scoreboard.json
@@ -8,6 +8,7 @@
"page": {"pass": 7, "fail": 0},
"page-full": {"pass": 4, "fail": 0},
"markdown": {"pass": 20, "fail": 0},
+ "runs": {"pass": 36, "fail": 0},
"text": {"pass": 20, "fail": 0},
"section": {"pass": 25, "fail": 0},
"compose": {"pass": 17, "fail": 0},
@@ -44,7 +45,7 @@
"md-doc": {"pass": 12, "fail": 0},
"fed": {"pass": 20, "fail": 0}
},
- "total_pass": 825,
+ "total_pass": 861,
"total_fail": 0,
- "total": 825
+ "total": 861
}
diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md
index d7db1c92..1efd80da 100644
--- a/lib/content/scoreboard.md
+++ b/lib/content/scoreboard.md
@@ -12,6 +12,7 @@ _Generated by `lib/content/conformance.sh`_
| page | 7 | 0 | 7 |
| page-full | 4 | 0 | 4 |
| markdown | 20 | 0 | 20 |
+| runs | 36 | 0 | 36 |
| text | 20 | 0 | 20 |
| section | 25 | 0 | 25 |
| compose | 17 | 0 | 17 |
@@ -47,4 +48,4 @@ _Generated by `lib/content/conformance.sh`_
| md-import | 38 | 0 | 38 |
| md-doc | 12 | 0 | 12 |
| fed | 20 | 0 | 20 |
-| **Total** | **825** | **0** | **825** |
+| **Total** | **861** | **0** | **861** |
diff --git a/lib/content/tests/runs.sx b/lib/content/tests/runs.sx
new file mode 100644
index 00000000..8a94f588
--- /dev/null
+++ b/lib/content/tests/runs.sx
@@ -0,0 +1,227 @@
+;; Phase 5 — rich inline text (structured runs). Acceptance suite.
+
+(st-bootstrap-classes!)
+(content/bootstrap!)
+(content-bootstrap-markdown!)
+(content-bootstrap-text!)
+(content-bootstrap-section!)
+(content-bootstrap-runs!)
+
+;; one-run helper: a CtText with a single marked run
+(define
+ one
+ (fn (marks href) (mk-rich-text "p" (list (mk-run "x" marks href)))))
+
+;; ── (1) four render modes ──
+;; a paragraph mixing plain + bold + a link
+(define
+ rd
+ (doc-append
+ (doc-empty "d")
+ (mk-rich-text
+ "p"
+ (list
+ (mk-run "the " (list) "")
+ (mk-run "cat" (list :bold) "")
+ (mk-run " and " (list) "")
+ (mk-run "dog" (list :italic :link) "/d")
+ (mk-run " sat" (list) "")))))
+(define p (doc-find rd "p"))
+(content-test
+ "asHTML rich"
+ (asHTML rd)
+ "the cat and dog sat
") +(content-test + "asMarkdown rich" + (asMarkdown rd) + "the **cat** and [_dog_](/d) sat") +(content-test + "asSx rich" + (asSx rd) + "(article (p \"the \" (strong \"cat\") \" and \" (a :href \"/d\" (em \"dog\")) \" sat\"))") +(content-test + "asText rich is plain (no markup)" + (asText rd) + "the cat and dog sat") + +;; every mark renders in HTML +(content-test + "mark bold html" + (asHTML (one (list :bold) "")) + "x
") +(content-test + "mark italic html" + (asHTML (one (list :italic) "")) + "x
") +(content-test + "mark underline html" + (asHTML (one (list :underline) "")) + "x
") +(content-test + "mark strike html" + (asHTML (one (list :strikethrough) "")) + "x
x
x
") +(content-test + "mark sup html" + (asHTML (one (list :superscript) "")) + "x
") +(content-test + "mark link html" + (asHTML (one (list :link) "/u")) + "") + +;; markdown marks +(content-test "mark bold md" (asMarkdown (one (list :bold) "")) "**x**") +(content-test "mark italic md" (asMarkdown (one (list :italic) "")) "_x_") +(content-test + "mark strike md" + (asMarkdown (one (list :strikethrough) "")) + "~~x~~") +(content-test "mark code md" (asMarkdown (one (list :code) "")) "`x`") +(content-test "mark link md" (asMarkdown (one (list :link) "/u")) "[x](/u)") +(content-test + "mark underline md fallback" + (asMarkdown (one (list :underline) "")) + "x") + +;; nested marks (bold+italic) — deterministic nesting order +(content-test + "nested marks html" + (asHTML (one (list :bold :italic) "")) + "x
") + +;; escaping still happens inside runs +(content-test + "run text escaped html" + (asHTML (mk-rich-text "p" (list (mk-run "a & ba & b <c>
") + +;; rich heading + quote + code +(content-test + "rich heading html" + (asHTML + (st-iv-set! + (mk-heading "h" 2 "") + "text" + (list (mk-run "Big " (list) "") (mk-run "bold" (list :bold) "")))) + "wise") +;; code is verbatim — runs concatenate as plain text, marks ignored +(content-test + "code runs plain html" + (asHTML + (st-iv-set! + (mk-code "c" "py" "") + "text" + (list (mk-run "a=" (list :bold) "") (mk-run "1" (list) "")))) + "
a=1")
+
+;; ── (2) backward compat: plain-string CtText unchanged ──
+(content-test
+ "plain html"
+ (asHTML (mk-text "q" "hi & "))
+ "hi & <b>
") +(content-test "plain sx" (asSx (mk-text "q" "hi")) "(p \"hi\")") +(content-test "plain md" (asMarkdown (mk-text "q" "hi")) "hi") +(content-test "plain text" (asText (mk-text "q" "hi")) "hi") +(content-test + "plain heading html" + (asHTML (mk-heading "h" 3 "T")) + "the Bar and Bar here
") +(content-test + "find-replace rich run0 still bold" + (nth (nth (blk-get (doc-find frr "p") "text") 0) 1) + (list "bold")) + +;; ── (4) search-text via asText, across run boundary ── +;; "cat sat" spans run1 ("the cat") and run2 (" sat") +(define + sd + (doc-append + (doc-empty "d") + (mk-rich-text + "p" + (list (mk-run "the cat" (list :bold) "") (mk-run " sat" (list) ""))))) +(content-test + "search finds substring across runs" + (content/search-text-ids sd "cat sat") + (list "p")) +(content-test "search miss" (content/search-text-ids sd "zzz") (list)) + +;; ── (5) CRDT invariant — runs are an opaque block-level value ── +(define ra (list (mk-run "x" (list :bold) ""))) +(define rb (list (mk-run "y" (list :italic) ""))) +(define + s1 + (crdt-insert + (crdt-empty) + "p" + "text" + (crdt-pos 5 "a") + {:text ra} + 1 + "a")) +(define s2 (crdt-update s1 "p" "text" rb 2 "b")) +(content-test + "crdt merge commutes with runs" + (get (crdt-merge s1 s2) :elements) + (get (crdt-merge s2 s1) :elements)) +(content-test + "crdt merge idempotent with runs" + (get (crdt-merge s2 s2) :elements) + (get s2 :elements)) +;; LWW: later ts (rb, ts 2) wins; runs survive as the field value +(content-test + "crdt LWW keeps latest runs" + (asHTML + (crdt-element->block (get (get (crdt-merge s1 s2) :elements) "p"))) + "y
") + +;; ── (6) data + wire round-trip runs losslessly ── +(content-test + "data round-trip rich html" + (asHTML (content/from-data (content/to-data rd))) + (asHTML rd)) +(content-test + "data round-trip rich text" + (asText (content/from-data (content/to-data rd))) + "the cat and dog sat") +(content-test + "wire round-trip rich html" + (asHTML (content/from-wire (content/to-wire rd))) + (asHTML rd)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 708b74e7..e4a16f27 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` → **825/825** (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` → **861/861** (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 @@ -113,6 +113,52 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [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) +## Phase 5 — rich inline text (structured runs) + +Drives the rose-ash blog migration: lexical post bodies carry inline formatting +(bold/italic/links) but `CtText` held one plain `text` string, so the canonical +lexical→blocks conversion was lossy. Variant **(b)**: a `CtText`'s `text` may be +EITHER a plain string (backward compat) OR a sequence of inline **runs**. Marks +cover the lexical bitmask (bold=1 italic=2 strike=4 underline=8 code=16 sub=32 +sup=64) plus link nodes (which carry an href). Applies to `CtText` and its +subclasses `CtHeading`/`CtQuote` (rich), and `CtCode` (verbatim — runs render as +plain concatenated text, no marks). + +**Run representation — a Smalltalk-renderable list, not a `{:text :marks}` dict.** +A run is `(text marks href)`: `text` a string, `marks` a list of mark tokens +(`:bold :italic :underline :strikethrough :code :subscript :superscript :link` — +SX keywords evaluate to the strings the renderer matches), `href` a string (`""` +when absent; carries the link target). *Why a list and not the dict the brief +sketched:* rendering must happen inside the Smalltalk render methods (nested +blocks dispatch `asHTML`/etc. through Smalltalk message sends), and the +Smalltalk-on-SX layer can iterate SX lists (`do:`/`inject:into:`) but **cannot** +read SX dict fields (`Dictionary>>at:` is broken in lib/smalltalk, which is out +of scope). Lists are Smalltalk-native, render under nesting, and round-trip +through data/wire for free (they're just nested lists+strings). The blog-side +lexical→runs converter targets this `(text marks href)` shape. + +Centralised in `runs.sx` (`content-bootstrap-runs!`) which OVERRIDES the +render/markdown/text methods of CtText/CtHeading/CtQuote/CtCode with run-aware +versions that fall through to identical output for plain strings — so it is +opt-in (the blog enables it) and the existing suites, which don't bootstrap it, +are untouched. + +- [x] runs render in all four modes — asHTML `//////`, asMarkdown `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup fall back to inline HTML), asSx emits nested run structure (`(p (strong "x") " y")`), asText returns the PLAIN concatenation (keeps search/stats/find-replace drift-proof)
+- [x] backward compat — a plain-string CtText still renders identically; existing suites stay green
+- [x] find-replace rewrites text across runs (per-run, marks preserved); runs join the text-bearing-field dispatch
+- [x] search-text finds substrings via asText, including across run boundaries
+- [x] CRDT invariant preserved — merge stays at BLOCK granularity (runs are the block's value): ops in any order / twice → identical document
+- [x] data + wire serialization round-trip runs losslessly
+
+### Future — Phase 6 (NOT in scope now)
+
+Variant **(c)**: character/run-level concurrent inline CRDT (Peritext/Yjs-style)
+so two authors can edit the same paragraph simultaneously — needed later for the
+multi-author SX editor that replaces Ghost. Block-granularity (b) is sufficient
+for the blog read-path migration. The lexical→runs converter itself lives on the
+blog/migration side (mark-set reference: `blog/bp/blog/ghost/lexical_to_sx.py`),
+not in lib/content.
+
## Known limitations
- **Markdown table cells containing `|` do not round-trip.** `asMarkdown` on a
@@ -137,6 +183,28 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
## Progress log
+- 2026-06-07 — **Phase 5 — rich inline text (structured runs) DONE.** A CtText's
+ `text` may now be a list of inline runs `(text marks href)` instead of a plain
+ string; CtHeading/CtQuote inherit rich rendering, CtCode renders runs as plain
+ verbatim text. New `runs.sx` (`content-bootstrap-runs!`) overrides the
+ render/markdown/text methods of CtText & subclasses with run-aware versions
+ that are byte-identical for plain-string bodies (opt-in; existing suites
+ untouched). All 4 modes: asHTML emits `//////`
+ + ``, asMarkdown emits `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup →
+ inline HTML), asSx emits nested run structure `(p "a" (strong "b"))` (matches
+ the SX editor's wire format), asText returns the PLAIN concatenation — so
+ search-text/stats/find-replace stay drift-proof. find-replace (`fr-rep-text`)
+ rewrites per run with marks preserved; search-text finds across run boundaries
+ via asText; CRDT merge treats the runs list as one block-level LWW value
+ (commutes/idempotent, verified); data + wire round-trip runs losslessly.
+ **Design note:** runs are a Smalltalk-renderable LIST, not the brief's
+ `{:text :marks}` dict — the Smalltalk-on-SX render methods (which must run
+ under nested dispatch) can iterate SX lists but cannot read SX dict fields
+ (`Dictionary>>at:` is broken in lib/smalltalk, out of scope). Marks are built
+ from `:bold`-style keywords (which evaluate to the strings the renderer
+ matches). Phase 6 (char-level concurrent inline CRDT) recorded as future, not
+ built. +36 runs tests (44 suites). 861/861.
+
- 2026-06-07 — Feature: `content/block-path` + `content/block-depth`
(block-path.sx, new suite). The read-side companion to doc-find-deep (locate
the block) and move-into/promote (relocate it): returns the ancestor-section