From 94b889c911307e47a1f00073dd8e1888fd15b83d Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 10:25:54 +0000 Subject: [PATCH 1/6] =?UTF-8?q?content:=20by-id=20ops=20(update/delete)=20?= =?UTF-8?q?act=20tree-wide=20=E2=80=94=20fixes=20op-log=20no-op=20on=20nes?= =?UTF-8?q?ted=20blocks=20+=204=20tests=20(750/750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/doc.sx | 43 +++++++++++++++++++++++++++++-------- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/store.sx | 27 +++++++++++++++++++++++ plans/content-on-sx.md | 10 ++++++++- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/lib/content/doc.sx b/lib/content/doc.sx index 5675d35b..a12c45fe 100644 --- a/lib/content/doc.sx +++ b/lib/content/doc.sx @@ -5,14 +5,18 @@ ;; and returns a NEW document — the input is never mutated, so any version is the ;; head of an op stream (replay-friendly for persist + CRDT merge). ;; -;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the -;; ergonomic API; they default nil and do not affect block operations. +;; By-id ops (update/delete) are TREE-WIDE: they descend into any block carrying +;; a `children` list (i.e. sections), since ids are unique across the tree. This +;; keeps the persist op-log and content/edit correct for nested documents. +;; insert/move are positional and act at the top level. +;; +;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx. ;; ;; Op shapes (data, not objects — they are the persist event payload): -;; {:op "insert" :block :after } ; after nil = prepend -;; {:op "update" :id :field :value } -;; {:op "move" :id :index } -;; {:op "delete" :id } +;; {:op "insert" :block :after } ; after nil = prepend (top level) +;; {:op "update" :id :field :value } ; tree-wide by id +;; {:op "move" :id :index } ; top level +;; {:op "delete" :id } ; tree-wide by id (define content-bootstrap-doc! @@ -76,17 +80,38 @@ (first blocks) (ct-insert-at (rest blocks) (- i 1) x)))))) +;; tree-wide remove by id: drop matches at this level, recurse into children +;; (blocks carrying a `children` list, i.e. sections). (define ct-remove-id (fn (blocks id) - (filter (fn (b) (if (= (blk-id b) id) false true)) blocks))) + (map + (fn + (b) + (let + ((ch (st-iv-get b "children"))) + (if (list? ch) (st-iv-set! b "children" (ct-remove-id ch id)) b))) + (filter (fn (b) (if (= (blk-id b) id) false true)) blocks)))) +;; tree-wide replace by id: apply f to the match wherever it sits in the tree. (define ct-replace-id (fn (blocks id f) - (map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks))) + (map + (fn + (b) + (if + (= (blk-id b) id) + (f b) + (let + ((ch (st-iv-get b "children"))) + (if + (list? ch) + (st-iv-set! b "children" (ct-replace-id ch id f)) + b)))) + blocks))) ;; ── query ── (define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id))) @@ -164,7 +189,7 @@ ;; ── op constructors (data payload, reused by persist op log) ── (define op-insert (fn (block after) {:after after :op "insert" :block block})) -(define op-update (fn (id field value) {:field field :id id :op "update" :value value})) +(define op-update (fn (id field value) {:id id :field field :op "update" :value value})) (define op-move (fn (id index) {:id id :op "move" :index index})) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index f5634352..4531cd82 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -31,7 +31,7 @@ "data": {"pass": 25, "fail": 0}, "wire": {"pass": 11, "fail": 0}, "validate": {"pass": 23, "fail": 0}, - "store": {"pass": 33, "fail": 0}, + "store": {"pass": 37, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "crdt-tree": {"pass": 21, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 746, + "total_pass": 750, "total_fail": 0, - "total": 746 + "total": 750 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 73064634..15c1ba85 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -35,7 +35,7 @@ _Generated by `lib/content/conformance.sh`_ | data | 25 | 0 | 25 | | wire | 11 | 0 | 11 | | validate | 23 | 0 | 23 | -| store | 33 | 0 | 33 | +| store | 37 | 0 | 37 | | snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | | crdt-tree | 21 | 0 | 21 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **746** | **0** | **746** | +| **Total** | **750** | **0** | **750** | diff --git a/lib/content/tests/store.sx b/lib/content/tests/store.sx index 56e0061a..f452b66d 100644 --- a/lib/content/tests/store.sx +++ b/lib/content/tests/store.sx @@ -151,3 +151,30 @@ "op-log media type" (blk-type (doc-find (content/head B3 "rich") "v")) "media") + +;; ── op-log update/delete reach NESTED blocks (tree-wide by id) ── +(content-bootstrap-section!) +(define B4 (persist/open)) +(content/commit! + B4 + "nest" + (op-insert (mk-section "sec" (list (mk-text "n" "orig"))) nil) + 1) +(content/commit! B4 "nest" (op-update "n" "text" "edited") 2) +(content-test + "op-log nested update" + (str (blk-send (doc-deep-find (content/head B4 "nest") "n") "text")) + "edited") +(content-test + "op-log nested update tree intact" + (doc-tree-ids (content/head B4 "nest")) + (list "sec" "n")) +(content/commit! B4 "nest" (op-delete "n") 3) +(content-test + "op-log nested delete" + (doc-tree-ids (content/head B4 "nest")) + (list "sec")) +(content-test + "op-log nested delete via content/at seq2" + (doc-tree-ids (content/at B4 "nest" 2)) + (list "sec" "n")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c25bdc7c..6187f5fd 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` → **746/746** (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` → **750/750** (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,14 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening: fixed a real layer seam (surfaced in the architecture + review) — by-id ops (update/delete) now act TREE-WIDE. `ct-replace-id` / + `ct-remove-id` (doc.sx) descend into any block carrying a `children` list, so + the persist op-log and `content/edit` correctly reach blocks nested in + sections (previously a silent no-op). `doc-move` stays top-level (guarded by + doc-find); insert/move remain positional. Inline section detection (no + section.sx dep). +4 store regression tests (nested update/delete via op-log + + replay-to-seq). Full gate over foundational doc.sx: 750/750. - 2026-06-07 — Hardening: audit confirmed the persist op-log (store.sx) carries every block type through commit → replay (op-insert carries the block instance; updates apply by id). Locked with +4 store tests (callout/media From 92c0c853a9c544aa4e533ebd9b3b58c20ca3d1a5 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 11:10:25 +0000 Subject: [PATCH 2/6] content: find-replace covers callout text + 2 tests (752/752) fr-has-text? now treats callout as text-bearing, matching asText/stats/ summary. content/find-replace previously skipped callout bodies silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/find-replace.sx | 11 +++++++---- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/find-replace.sx | 27 +++++++++++++++++++++++++++ plans/content-on-sx.md | 9 ++++++++- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/lib/content/find-replace.sx b/lib/content/find-replace.sx index 4c22e179..2b9b34f5 100644 --- a/lib/content/find-replace.sx +++ b/lib/content/find-replace.sx @@ -1,8 +1,9 @@ ;; content-on-sx — global find/replace across text-bearing blocks. ;; -;; Replaces every occurrence of `from` with `to` in the text field of text / -;; heading / code / quote blocks, tree-wide (via the transform layer). For -;; renaming a term throughout a document. Immutable; case-sensitive. +;; Replaces every occurrence of `from` with `to` in the `text` field of +;; text / heading / code / quote / callout blocks, tree-wide (via the transform +;; layer). For renaming a term throughout a document. Immutable; case-sensitive. +;; (Same text-bearing set asText/stats/summary treat as content.) ;; ;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks). @@ -17,7 +18,9 @@ (define fr-has-text? - (fn (b) (fr-in? (blk-type b) (list "text" "heading" "code" "quote")))) + (fn + (b) + (fr-in? (blk-type b) (list "text" "heading" "code" "quote" "callout")))) (define content/find-replace diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 4531cd82..1dbed8dc 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -21,7 +21,7 @@ "flatten": {"pass": 10, "fail": 0}, "transform": {"pass": 12, "fail": 0}, "normalize": {"pass": 11, "fail": 0}, - "find-replace": {"pass": 10, "fail": 0}, + "find-replace": {"pass": 12, "fail": 0}, "stats": {"pass": 17, "fail": 0}, "summary": {"pass": 14, "fail": 0}, "index": {"pass": 13, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 750, + "total_pass": 752, "total_fail": 0, - "total": 750 + "total": 752 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 15c1ba85..09e808d9 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -25,7 +25,7 @@ _Generated by `lib/content/conformance.sh`_ | flatten | 10 | 0 | 10 | | transform | 12 | 0 | 12 | | normalize | 11 | 0 | 11 | -| find-replace | 10 | 0 | 10 | +| find-replace | 12 | 0 | 12 | | stats | 17 | 0 | 17 | | summary | 14 | 0 | 14 | | index | 13 | 0 | 13 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **750** | **0** | **750** | +| **Total** | **752** | **0** | **752** | diff --git a/lib/content/tests/find-replace.sx b/lib/content/tests/find-replace.sx index 67e721d9..742dc3c0 100644 --- a/lib/content/tests/find-replace.sx +++ b/lib/content/tests/find-replace.sx @@ -3,6 +3,7 @@ (st-bootstrap-classes!) (content/bootstrap!) (content-bootstrap-section!) +(content-bootstrap-callout!) (define d @@ -76,6 +77,32 @@ (str (blk-send (doc-find r2 "q") "text")) "new saying") +;; ── callout text is now covered (consistency with asText/stats/summary) ── +(content-test + "replace callout text" + (str + (blk-send + (doc-find + (content/find-replace + (doc-append (doc-empty "d") (mk-callout "co" "note" "Foo here")) + "Foo" + "Bar") + "co") + "text")) + "Bar here") +(content-test + "callout kind untouched by text replace" + (str + (blk-send + (doc-find + (content/find-replace + (doc-append (doc-empty "d") (mk-callout "co" "note" "x")) + "note" + "X") + "co") + "kind")) + "note") + ;; ── no match → unchanged render ── (content-test "no match" diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 6187f5fd..7bf39f68 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` → **750/750** (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` → **752/752** (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,13 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Consistency: `find-replace` now covers `callout` text. `fr-has-text?` + (find-replace.sx) added `callout` to its text-bearing block kinds, matching + `asText`/stats/summary which already treat callout bodies as prose. Previously a + `content/find-replace` over a doc containing callouts silently skipped them. +2 + find-replace tests (replace callout text; callout kind untouched by text replace). + 752/752 (41 suites). + - 2026-06-07 — Hardening: fixed a real layer seam (surfaced in the architecture review) — by-id ops (update/delete) now act TREE-WIDE. `ct-replace-id` / `ct-remove-id` (doc.sx) descend into any block carrying a `children` list, so From 2f626173d9936d6f8730ca001e254efefda55b6c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:05:11 +0000 Subject: [PATCH 3/6] content: find-replace rewrites all text-bearing fields (756/756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fr-rewrite dispatches per block type so image alt, list items, and table headers/cells are renamed alongside text/heading/code/quote/callout — matching exactly the set asText/stats/word-count fold into prose. Prior find-replace skipped them, so a rename stayed visible in counts/exports. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/find-replace.sx | 67 +++++++++++++++++++++++++------ lib/content/scoreboard.json | 6 +-- lib/content/scoreboard.md | 4 +- lib/content/tests/find-replace.sx | 48 +++++++++++++++++++--- plans/content-on-sx.md | 12 +++++- 5 files changed, 113 insertions(+), 24 deletions(-) diff --git a/lib/content/find-replace.sx b/lib/content/find-replace.sx index 2b9b34f5..e6ee76b7 100644 --- a/lib/content/find-replace.sx +++ b/lib/content/find-replace.sx @@ -1,11 +1,17 @@ -;; content-on-sx — global find/replace across text-bearing blocks. +;; content-on-sx — global find/replace across every text-bearing field. ;; -;; Replaces every occurrence of `from` with `to` in the `text` field of -;; text / heading / code / quote / callout blocks, tree-wide (via the transform -;; layer). For renaming a term throughout a document. Immutable; case-sensitive. -;; (Same text-bearing set asText/stats/summary treat as content.) +;; Replaces every occurrence of `from` with `to` in the text-bearing fields of +;; a document, tree-wide (via the transform layer): +;; - the `text` of text / heading / code / quote / callout blocks +;; - the `alt` of image blocks +;; - each item of list blocks +;; - every header and cell of table blocks +;; This is exactly the set asText / stats / summary draw prose from, so a rename +;; via content/find-replace and a word count over asText stay consistent. +;; Immutable; case-sensitive. ;; -;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks). +;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks), +;; table.sx (CtTable ivars). (define fr-in? @@ -16,19 +22,54 @@ ((= (first xs) x) true) (else (fr-in? x (rest xs)))))) +(define fr-rep (fn (s from to) (replace (str s) from to))) + +;; Blocks whose prose content find/replace rewrites (matches asText's set). (define fr-has-text? (fn (b) - (fr-in? (blk-type b) (list "text" "heading" "code" "quote" "callout")))) + (fr-in? + (blk-type b) + (list "text" "heading" "code" "quote" "callout" "image" "list" "table")))) + +;; Per-type field rewrite. Each branch returns a new (copy-on-write) block. +(define + fr-rewrite + (fn + (b from to) + (let + ((t (blk-type b))) + (cond + ((= t "image") + (blk-set b "alt" (fr-rep (blk-get b "alt") from to))) + ((= t "list") + (let + ((items (blk-get b "items"))) + (if + (list? items) + (blk-set b "items" (map (fn (it) (fr-rep it from to)) items)) + b))) + ((= t "table") + (let + ((hs (blk-get b "headers")) (rs (blk-get b "rows"))) + (let + ((b1 (if (list? hs) (blk-set b "headers" (map (fn (h) (fr-rep h from to)) hs)) b))) + (if + (list? rs) + (blk-set + b1 + "rows" + (map + (fn + (r) + (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))))))) (define content/find-replace (fn (doc from to) - (content/map-blocks - doc - fr-has-text? - (fn - (b) - (blk-set b "text" (replace (str (blk-get b "text")) from to)))))) + (content/map-blocks doc fr-has-text? (fn (b) (fr-rewrite b from to))))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 1dbed8dc..cb2a09cf 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -21,7 +21,7 @@ "flatten": {"pass": 10, "fail": 0}, "transform": {"pass": 12, "fail": 0}, "normalize": {"pass": 11, "fail": 0}, - "find-replace": {"pass": 12, "fail": 0}, + "find-replace": {"pass": 16, "fail": 0}, "stats": {"pass": 17, "fail": 0}, "summary": {"pass": 14, "fail": 0}, "index": {"pass": 13, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 752, + "total_pass": 756, "total_fail": 0, - "total": 752 + "total": 756 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 09e808d9..14c2c9b7 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -25,7 +25,7 @@ _Generated by `lib/content/conformance.sh`_ | flatten | 10 | 0 | 10 | | transform | 12 | 0 | 12 | | normalize | 11 | 0 | 11 | -| find-replace | 12 | 0 | 12 | +| find-replace | 16 | 0 | 16 | | stats | 17 | 0 | 17 | | summary | 14 | 0 | 14 | | index | 13 | 0 | 13 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **752** | **0** | **752** | +| **Total** | **756** | **0** | **756** | diff --git a/lib/content/tests/find-replace.sx b/lib/content/tests/find-replace.sx index 742dc3c0..47e802eb 100644 --- a/lib/content/tests/find-replace.sx +++ b/lib/content/tests/find-replace.sx @@ -1,9 +1,10 @@ -;; Extension — global find/replace across text-bearing blocks. +;; Extension — global find/replace across every text-bearing field. (st-bootstrap-classes!) (content/bootstrap!) (content-bootstrap-section!) (content-bootstrap-callout!) +(content-bootstrap-table!) (define d @@ -31,11 +32,12 @@ (str (blk-send (doc-deep-find r "n") "text")) "nested Bar") -;; ── does NOT touch image alt/src (not a text field) ── +;; ── image alt IS a text field (asText ^ alt), so it is rewritten ── (content-test - "image alt untouched" + "image alt replaced" (str (blk-send (doc-deep-find r "img") "alt")) - "Foo alt") + "Bar alt") +;; ── but src is a URL, not prose, so it stays put ── (content-test "image src untouched" (str (blk-send (doc-deep-find r "img") "src")) @@ -77,7 +79,7 @@ (str (blk-send (doc-find r2 "q") "text")) "new saying") -;; ── callout text is now covered (consistency with asText/stats/summary) ── +;; ── callout text is covered (consistency with asText/stats/summary) ── (content-test "replace callout text" (str @@ -103,6 +105,42 @@ "kind")) "note") +;; ── list items are rewritten (asText folds items) ── +(define + rl + (content/find-replace + (doc-append + (doc-empty "d") + (mk-list "l" false (list "Foo one" "two Foo"))) + "Foo" + "Bar")) +(content-test + "replace first list item" + (str (first (blk-send (doc-find rl "l") "items"))) + "Bar one") +(content-test + "replace second list item" + (str (first (rest (blk-send (doc-find rl "l") "items")))) + "two Bar") + +;; ── table headers + cells are rewritten (asText folds rows) ── +(define + rt + (content/find-replace + (doc-append + (doc-empty "d") + (mk-table "t" (list "Foo head") (list (list "a Foo" "b")))) + "Foo" + "Bar")) +(content-test + "replace table header" + (str (first (table-headers (doc-find rt "t")))) + "Bar head") +(content-test + "replace table cell" + (str (first (first (table-rows (doc-find rt "t"))))) + "a Bar") + ;; ── no match → unchanged render ── (content-test "no match" diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 7bf39f68..d0de3f9a 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` → **752/752** (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` → **756/756** (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,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Consistency: `find-replace` now rewrites **every** text-bearing + field, not just `text`. New `fr-rewrite` dispatches per block type — `alt` of + image blocks, each item of list blocks, and every header/cell of table blocks + now get rewritten alongside text/heading/code/quote/callout. This closes a real + seam: `asText`/stats/word-count already fold image alt, list items, and table + cells into a document's prose, so a `content/find-replace` rename that skipped + them was inconsistent (a renamed term would still show up in word counts and + exports). Flipped the two `image alt untouched` tests to `image alt replaced`; + +4 tests (list items ×2, table header + cell). find-replace 16/16, 756/756. + - 2026-06-07 — Consistency: `find-replace` now covers `callout` text. `fr-has-text?` (find-replace.sx) added `callout` to its text-bearing block kinds, matching `asText`/stats/summary which already treat callout bodies as prose. Previously a From 4d889716a350d4f4a72453e6554a7aecbf444430 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:52:34 +0000 Subject: [PATCH 4/6] content: in-document prose search via asText (763/763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content/search-text + search-text-ids find every block whose (asText b) contains a term — spanning all text-bearing fields by reusing the canonical asText projection, so it can't drift from stats/find-replace. Section wrappers excluded. +7 query tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/query.sx | 30 +++++++++++++++++++--- lib/content/scoreboard.json | 6 ++--- lib/content/scoreboard.md | 4 +-- lib/content/tests/query.sx | 51 ++++++++++++++++++++++++++++++++++++- plans/content-on-sx.md | 12 ++++++++- 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/lib/content/query.sx b/lib/content/query.sx index a312a45b..3f7a6156 100644 --- a/lib/content/query.sx +++ b/lib/content/query.sx @@ -1,10 +1,10 @@ ;; 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. +;; or type, search them by prose, 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. +;; Requires (loaded by harness): block.sx, doc.sx, text.sx (asText for search). (define qry-section? @@ -45,6 +45,30 @@ content/select-ids (fn (doc pred) (map (fn (b) (blk-id b)) (content/select doc pred)))) +;; Blocks (tree-wide, excluding section containers) whose own prose contains +;; `term`. "Prose" is (asText b), so search covers exactly what every block +;; exposes as text — text/heading/code/quote/callout text, image alt, list +;; items, table headers+cells — with no separate field list to drift from +;; asText / find-replace / stats. Case-sensitive substring match. +(define + content/search-text + (fn + (doc term) + (content/select + doc + (fn + (b) + (and + (not (qry-section? b)) + (>= (index-of (asText b) term) 0)))))) + +;; Same search, returning matching block ids in document order. +(define + content/search-text-ids + (fn + (doc term) + (map (fn (b) (blk-id b)) (content/search-text doc term)))) + ;; table of contents: {:id :level :text} for every heading, in document order. (define content/headings diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index cb2a09cf..30edd305 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -14,7 +14,7 @@ "tree-edit": {"pass": 17, "fail": 0}, "move": {"pass": 11, "fail": 0}, "clone": {"pass": 10, "fail": 0}, - "query": {"pass": 13, "fail": 0}, + "query": {"pass": 20, "fail": 0}, "toc": {"pass": 8, "fail": 0}, "anchor": {"pass": 6, "fail": 0}, "outline": {"pass": 14, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 756, + "total_pass": 763, "total_fail": 0, - "total": 756 + "total": 763 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 14c2c9b7..5ce5087f 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -18,7 +18,7 @@ _Generated by `lib/content/conformance.sh`_ | tree-edit | 17 | 0 | 17 | | move | 11 | 0 | 11 | | clone | 10 | 0 | 10 | -| query | 13 | 0 | 13 | +| query | 20 | 0 | 20 | | toc | 8 | 0 | 8 | | anchor | 6 | 0 | 6 | | outline | 14 | 0 | 14 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **756** | **0** | **756** | +| **Total** | **763** | **0** | **763** | diff --git a/lib/content/tests/query.sx b/lib/content/tests/query.sx index 873c82cc..e6695bf5 100644 --- a/lib/content/tests/query.sx +++ b/lib/content/tests/query.sx @@ -1,8 +1,11 @@ -;; Extension — block query + table of contents. +;; Extension — block query + table of contents + prose search. (st-bootstrap-classes!) (content/bootstrap!) +(content-bootstrap-text!) (content-bootstrap-section!) +(content-bootstrap-table!) +(content-bootstrap-callout!) (define d @@ -87,3 +90,49 @@ "deep toc level" (get (first (content/headings deep)) :level) 3) + +;; ── prose search (content/search-text) ── +;; "cat" appears in text, image alt, a list item, a table cell, and a callout +;; — every text-bearing field — so search must find all five via asText. +(define + sd + (doc-append + (doc-append + (doc-append + (doc-append + (doc-append + (doc-empty "sd") + (mk-heading "sh" 1 "Welcome aboard")) + (mk-text "st" "the cat sat")) + (mk-image "si" "/x.png" "a cat photo")) + (mk-list "sl" false (list "first cat" "second dog"))) + (mk-section + "sec" + (list + (mk-table "stb" (list "Animal") (list (list "cat") (list "fish"))) + (mk-callout "sc" "note" "beware of cat"))))) + +(content-test + "search across every text-bearing field" + (content/search-text-ids sd "cat") + (list "st" "si" "sl" "stb" "sc")) +(content-test "search count" (len (content/search-text sd "cat")) 5) +(content-test + "search heading text" + (content/search-text-ids sd "Welcome") + (list "sh")) +(content-test + "search list item only" + (content/search-text-ids sd "dog") + (list "sl")) +(content-test "search no match" (content/search-text-ids sd "zzz") (list)) +;; section containers are excluded — a term living only inside a section's +;; children returns the child, never the section wrapper. +(content-test + "search excludes section wrapper" + (content/search-text-ids sd "fish") + (list "stb")) +(content-test + "search returns block objects" + (blk-id (first (content/search-text sd "Welcome"))) + "sh") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index d0de3f9a..c36178d4 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` → **756/756** (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` → **763/763** (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,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Feature: in-document prose search. `content/search-text` (and + `content/search-text-ids`) return every content block, tree-wide, whose + `(asText b)` contains a term — so search spans text/heading/code/quote/callout + text, image alt, list items and table cells **by construction**: it reuses the + one canonical "prose of a block" projection (asText) rather than re-listing + fields, so it can't drift from stats/find-replace. Section containers are + excluded (a term living only in a section's children returns the child, not the + wrapper). +7 query tests (cross-field match, count, single-field, no-match, + section exclusion, object return). 763/763. + - 2026-06-07 — Consistency: `find-replace` now rewrites **every** text-bearing field, not just `text`. New `fr-rewrite` dispatches per block type — `alt` of image blocks, each item of list blocks, and every header/cell of table blocks From 9051f52f53ac88d9331aac7c2511431657588ea6 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 13:39:08 +0000 Subject: [PATCH 5/6] content: tree-wide revision diff (772/772) content/diff + diff-versions enumerated ids top-level only (doc-ids/ doc-find), so diffs of documents with sections missed every nested add/ remove/change. Now via doc-tree-ids + doc-deep-find; sections excluded from :changed (no own content), still reported in :added/:removed. Flat-doc diffs unchanged. +9 store tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/store.sx | 31 ++++++++++++++++++++----------- lib/content/tests/store.sx | 28 ++++++++++++++++++++++++++++ plans/content-on-sx.md | 14 +++++++++++++- 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 30edd305..34cca8ab 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -31,7 +31,7 @@ "data": {"pass": 25, "fail": 0}, "wire": {"pass": 11, "fail": 0}, "validate": {"pass": 23, "fail": 0}, - "store": {"pass": 37, "fail": 0}, + "store": {"pass": 46, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "crdt-tree": {"pass": 21, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 763, + "total_pass": 772, "total_fail": 0, - "total": 763 + "total": 772 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 5ce5087f..b3e0a611 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -35,7 +35,7 @@ _Generated by `lib/content/conformance.sh`_ | data | 25 | 0 | 25 | | wire | 11 | 0 | 11 | | validate | 23 | 0 | 23 | -| store | 37 | 0 | 37 | +| store | 46 | 0 | 46 | | snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | | crdt-tree | 21 | 0 | 21 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **763** | **0** | **763** | +| **Total** | **772** | **0** | **772** | diff --git a/lib/content/store.sx b/lib/content/store.sx index ef840c13..d59ddbb9 100644 --- a/lib/content/store.sx +++ b/lib/content/store.sx @@ -5,9 +5,10 @@ ;; replay of its op stream up to a sequence number; the materialised doc is a ;; cache, never primary state. ;; -;; Requires (loaded by the harness): block.sx, doc.sx, and persist -;; (event/backend/log/kv/api). The persist backend `b` is opened by the caller -;; via (persist/open) and injected — content knows nothing about which backend. +;; Requires (loaded by the harness): block.sx, doc.sx, section.sx (doc-deep-find +;; + doc-tree-ids, for the tree-wide diff), plus persist (event/backend/log/kv/ +;; api). The persist backend `b` is opened by the caller via (persist/open) and +;; injected — content knows nothing about which backend. (define content/-stream (fn (doc-id) (str "content:" doc-id))) @@ -69,11 +70,18 @@ (fn (b doc-id) (map (fn (ev) {:type (persist/event-type ev) :at (persist/event-at ev) :seq (persist/event-seq ev)}) (content/log b doc-id)))) ;; ── diff between two materialised document versions ── -;; Returns {:added (ids) :removed (ids) :changed (ids)} where changed = ids -;; present in both whose block content differs. -(define - content/-missing? - (fn (doc id) (= (ct-index-of (doc-blocks doc) id) -1))) +;; Tree-wide: ids are enumerated across the whole block tree (descending into +;; sections), so nested-block adds/removes/changes are detected, not just +;; top-level ones. Returns {:added :removed :changed} (lists of ids): +;; :added — ids present (anywhere) in `new` but not in `old` +;; :removed — ids present (anywhere) in `old` but not in `new` +;; :changed — content blocks present in both whose block value differs +;; Section containers never appear in :changed (they hold no own content — a +;; child change surfaces as that child's own entry); a whole section appearing +;; or disappearing shows up in :added / :removed by its id. +(define content/-all-ids (fn (doc) (doc-tree-ids doc))) + +(define content/-missing? (fn (doc id) (= (doc-deep-find doc id) nil))) (define content/-changed @@ -83,15 +91,16 @@ (fn (id) (let - ((bo (doc-find old id)) (bn (doc-find new id))) + ((bo (doc-deep-find old id)) (bn (doc-deep-find new id))) (cond ((= bo nil) false) ((= bn nil) false) + ((= (blk-type bo) "section") false) ((= bo bn) false) (else true)))) - (doc-ids old)))) + (content/-all-ids old)))) -(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (doc-ids old)) :added (filter (fn (id) (content/-missing? old id)) (doc-ids new))})) +(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (content/-all-ids old)) :added (filter (fn (id) (content/-missing? old id)) (content/-all-ids new))})) ;; convenience: diff two persisted versions by seq. (define diff --git a/lib/content/tests/store.sx b/lib/content/tests/store.sx index f452b66d..d8fb85cb 100644 --- a/lib/content/tests/store.sx +++ b/lib/content/tests/store.sx @@ -178,3 +178,31 @@ "op-log nested delete via content/at seq2" (doc-tree-ids (content/at B4 "nest" 2)) (list "sec" "n")) + +;; ── diff is TREE-WIDE: nested-block add/change/remove are detected, and +;; section containers never appear in :changed (a top-level-only diff would miss +;; "n" entirely and instead flag the section). ── +(define dn01 (content/diff-versions B4 "nest" 0 1)) +(content-test + "diff nested added (section + child)" + (get dn01 :added) + (list "sec" "n")) +(content-test "diff nested added removed empty" (get dn01 :removed) (list)) +(content-test "diff nested added changed empty" (get dn01 :changed) (list)) + +(define dn12 (content/diff-versions B4 "nest" 1 2)) +(content-test + "diff nested changed child only" + (get dn12 :changed) + (list "n")) +(content-test "diff nested changed no add" (get dn12 :added) (list)) +(content-test "diff nested changed no remove" (get dn12 :removed) (list)) + +(define dn23 (content/diff-versions B4 "nest" 2 3)) +(content-test "diff nested removed child" (get dn23 :removed) (list "n")) +(content-test "diff nested removed no change" (get dn23 :changed) (list)) + +(content-test + "diff nested no-op" + (get (content/diff-versions B4 "nest" 1 1) :changed) + (list)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c36178d4..c1f8aaa1 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` → **763/763** (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` → **772/772** (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,18 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening: `content/diff` (and `content/diff-versions`) are now + TREE-WIDE. They enumerated ids via `doc-ids`/`doc-find` (top-level only), so a + diff between two versions of a document containing sections silently missed + every nested-block add/remove/change — the same class of seam as the by-id + op-log bug. Now ids come from `doc-tree-ids` and lookups from `doc-deep-find`, + so nested changes surface precisely. Section containers are excluded from + `:changed` (they hold no own content; a child change reports as that child), + while whole-section add/remove still shows in `:added`/`:removed`. Flat-doc + diffs are unchanged (deep == top-level with no sections). +9 store tests + (nested add = section+child, nested change = child only, nested remove, + no-op). 772/772. + - 2026-06-07 — Feature: in-document prose search. `content/search-text` (and `content/search-text-ids`) return every content block, tree-wide, whose `(asText b)` contains a term — so search spans text/heading/code/quote/callout From c9a8f05244c28481e2594b2437d4032386369380 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:49:15 +0000 Subject: [PATCH 6/6] content: tree-wide content/find + has? (778/778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facade read-by-id was top-level only while content/edit's update/delete are tree-wide — could not read back a nested block content/edit just modified. Added generic ct-find-id (doc.sx) + doc-find-deep/doc-has-deep?; content/find + has? now descend into sections. content/find-top/has-top? keep top-level lookup. Audit: remaining doc-find/ct-index-of callers are positional insert/move (top-level by design). +6 api tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/api.sx | 9 +++++++-- lib/content/doc.sx | 37 +++++++++++++++++++++++++++++++++---- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/api.sx | 34 ++++++++++++++++++++++++++++++++++ plans/content-on-sx.md | 15 ++++++++++++++- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/lib/content/api.sx b/lib/content/api.sx index 6c9535b4..d4eef25f 100644 --- a/lib/content/api.sx +++ b/lib/content/api.sx @@ -25,8 +25,13 @@ (define content/append doc-append) (define content/blocks doc-blocks) (define content/count doc-count) -(define content/find doc-find) -(define content/has? doc-has?) +;; find / has? are TREE-WIDE by id (descend into sections) — so the facade reads +;; back any block content/edit can update or delete. content/find-top / has-top? +;; keep the top-level-only lookup for callers that mean the ordered sequence. +(define content/find doc-find-deep) +(define content/has? doc-has-deep?) +(define content/find-top doc-find) +(define content/has-top? doc-has?) (define content/ids doc-ids) (define content/types doc-types) diff --git a/lib/content/doc.sx b/lib/content/doc.sx index a12c45fe..92130cbb 100644 --- a/lib/content/doc.sx +++ b/lib/content/doc.sx @@ -5,9 +5,10 @@ ;; and returns a NEW document — the input is never mutated, so any version is the ;; head of an op stream (replay-friendly for persist + CRDT merge). ;; -;; By-id ops (update/delete) are TREE-WIDE: they descend into any block carrying -;; a `children` list (i.e. sections), since ids are unique across the tree. This -;; keeps the persist op-log and content/edit correct for nested documents. +;; By-id ops (update/delete) and by-id lookup (doc-find-deep/doc-has-deep?) are +;; TREE-WIDE: they descend into any block carrying a `children` list (i.e. +;; sections), since ids are unique across the tree. This keeps the persist +;; op-log, content/edit and content/find correct for nested documents. ;; insert/move are positional and act at the top level. ;; ;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx. @@ -113,6 +114,26 @@ b)))) blocks))) +;; tree-wide find by id: first block matching id anywhere in the tree, or nil. +;; Descends into any `children` list, mirroring ct-replace-id/ct-remove-id. +(define + ct-find-id + (fn + (blocks id) + (if + (= (len blocks) 0) + nil + (let + ((b (first blocks))) + (if + (= (blk-id b) id) + b + (let + ((ch (st-iv-get b "children"))) + (let + ((nested (if (list? ch) (ct-find-id ch id) nil))) + (if (= nested nil) (ct-find-id (rest blocks) id) nested)))))))) + ;; ── query ── (define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id))) @@ -128,6 +149,14 @@ doc-has? (fn (doc id) (if (= (doc-index-of doc id) -1) false true))) +;; tree-wide lookup by id — reads a nested block by the same id content/edit can +;; update/delete (no section.sx dependency; uses the generic children descent). +(define doc-find-deep (fn (doc id) (ct-find-id (doc-blocks doc) id))) + +(define + doc-has-deep? + (fn (doc id) (if (= (doc-find-deep doc id) nil) false true))) + ;; ── structural edits (each returns a new document) ── (define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks))) @@ -189,7 +218,7 @@ ;; ── op constructors (data payload, reused by persist op log) ── (define op-insert (fn (block after) {:after after :op "insert" :block block})) -(define op-update (fn (id field value) {:id id :field field :op "update" :value value})) +(define op-update (fn (id field value) {:field field :id id :op "update" :value value})) (define op-move (fn (id index) {:id id :op "move" :index index})) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 34cca8ab..7bb424af 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -3,7 +3,7 @@ "block": {"pass": 38, "fail": 0}, "doc": {"pass": 40, "fail": 0}, "render": {"pass": 42, "fail": 0}, - "api": {"pass": 26, "fail": 0}, + "api": {"pass": 32, "fail": 0}, "meta": {"pass": 27, "fail": 0}, "page": {"pass": 7, "fail": 0}, "page-full": {"pass": 4, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 772, + "total_pass": 778, "total_fail": 0, - "total": 772 + "total": 778 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index b3e0a611..ca91ab02 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -7,7 +7,7 @@ _Generated by `lib/content/conformance.sh`_ | block | 38 | 0 | 38 | | doc | 40 | 0 | 40 | | render | 42 | 0 | 42 | -| api | 26 | 0 | 26 | +| api | 32 | 0 | 32 | | meta | 27 | 0 | 27 | | page | 7 | 0 | 7 | | page-full | 4 | 0 | 4 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **772** | **0** | **772** | +| **Total** | **778** | **0** | **778** | diff --git a/lib/content/tests/api.sx b/lib/content/tests/api.sx index 4c3fa3ea..47ca4160 100644 --- a/lib/content/tests/api.sx +++ b/lib/content/tests/api.sx @@ -97,3 +97,37 @@ "render original unchanged" (content/render d1 "html") "

Hi

World

") + +;; ── facade find/has? are TREE-WIDE (reach into sections); find-top/has-top? +;; keep the top-level-only lookup. This makes the read-by-id surface consistent +;; with content/edit, whose update/delete are already tree-wide. ── +(content-bootstrap-section!) +(define + nd + (content/append + (content/empty "nested") + (mk-section + "sec" + (list (content/block "text" "inner" (list (list "text" "deep"))))))) +(content-test + "find nested (deep)" + (blk-id (content/find nd "inner")) + "inner") +(content-test "has? nested (deep)" (content/has? nd "inner") true) +(content-test "find-top misses nested" (content/find-top nd "inner") nil) +(content-test "has-top? misses nested" (content/has-top? nd "inner") false) +(content-test + "find-top sees top-level" + (blk-id (content/find-top nd "sec")) + "sec") +;; a nested block updated by id via content/edit is now readable by id via +;; content/find (was impossible when find was top-level-only). +(content-test + "edit-then-find nested round-trip" + (str + (blk-send + (content/find + (content/edit nd (content/update "inner" "text" "edited")) + "inner") + "text")) + "edited") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c1f8aaa1..bbb65212 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` → **772/772** (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` → **778/778** (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,19 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening (tree-wide audit): the public facade `content/find` / + `content/has?` were top-level-only (`doc-find`/`doc-has?`), so you could + `content/edit` an update/delete to a nested block by id (those ops are + tree-wide) but couldn't read that same block back by id through the facade — a + concrete read/write asymmetry. Added a generic `ct-find-id` to doc.sx (descends + into any `children` list, mirroring ct-replace-id/ct-remove-id, no section.sx + dependency) plus `doc-find-deep`/`doc-has-deep?`; `content/find`/`content/has?` + now point at them. Kept `content/find-top`/`content/has-top?` for the + top-level-only lookup. Audited all `doc-find`/`doc-ids`/`ct-index-of` callers: + the remaining ones are insert/move (positional, top-level by design) — no other + seams. +6 api tests (nested deep find/has, top variants miss nested, + edit-then-find round-trip). 778/778. + - 2026-06-07 — Hardening: `content/diff` (and `content/diff-versions`) are now TREE-WIDE. They enumerated ids via `doc-ids`/`doc-find` (top-level only), so a diff between two versions of a document containing sections silently missed