diff --git a/lib/content/anchor.sx b/lib/content/anchor.sx
new file mode 100644
index 00000000..368b1d35
--- /dev/null
+++ b/lib/content/anchor.sx
@@ -0,0 +1,51 @@
+;; content-on-sx — anchored-heading HTML render.
+;;
+;; Like asHTML, but headings carry an id attribute (the block id), so the TOC's
+;; #id links resolve. A separate render so the plain asHTML stays unchanged.
+;; Tree-aware (sections recurse); other blocks use their normal asHTML.
+;;
+;; Requires (loaded by harness): block.sx, doc.sx, render.sx (asHTML +
+;; htmlEscaped).
+
+(define
+ anch-section?
+ (fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
+(define anch-esc (fn (s) (str (st-send s "htmlEscaped" (list)))))
+
+(define
+ anchor-block
+ (fn
+ (b)
+ (cond
+ ((= (blk-type b) "heading")
+ (let
+ ((l (str (blk-get b "level"))) (id (blk-id b)))
+ (str
+ ""
+ (anch-esc (str (blk-get b "text")))
+ "")))
+ ((anch-section? b)
+ (let
+ ((ch (st-iv-get b "children")))
+ (str
+ ""
+ (anchor-blocks (if (list? ch) ch (list)))
+ "")))
+ (else (str (st-send b "asHTML" (list)))))))
+
+(define
+ anchor-blocks
+ (fn
+ (blocks)
+ (if
+ (= (len blocks) 0)
+ ""
+ (str (anchor-block (first blocks)) (anchor-blocks (rest blocks))))))
+
+(define content/html-anchored (fn (doc) (anchor-blocks (doc-blocks doc))))
diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh
index 3684cb6d..63d63af8 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 toc transform normalize find-replace 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 toc anchor transform normalize find-replace 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"
@@ -50,6 +50,7 @@ run_suite() {
(load "lib/content/clone.sx")
(load "lib/content/query.sx")
(load "lib/content/toc.sx")
+(load "lib/content/anchor.sx")
(load "lib/content/transform.sx")
(load "lib/content/normalize.sx")
(load "lib/content/find-replace.sx")
diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json
index 1dbfc1ac..75327131 100644
--- a/lib/content/scoreboard.json
+++ b/lib/content/scoreboard.json
@@ -15,6 +15,7 @@
"clone": {"pass": 10, "fail": 0},
"query": {"pass": 13, "fail": 0},
"toc": {"pass": 8, "fail": 0},
+ "anchor": {"pass": 6, "fail": 0},
"transform": {"pass": 12, "fail": 0},
"normalize": {"pass": 11, "fail": 0},
"find-replace": {"pass": 10, "fail": 0},
@@ -32,7 +33,7 @@
"md-doc": {"pass": 12, "fail": 0},
"fed": {"pass": 20, "fail": 0}
},
- "total_pass": 615,
+ "total_pass": 621,
"total_fail": 0,
- "total": 615
+ "total": 621
}
diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md
index a7211172..9c8a40c3 100644
--- a/lib/content/scoreboard.md
+++ b/lib/content/scoreboard.md
@@ -19,6 +19,7 @@ _Generated by `lib/content/conformance.sh`_
| clone | 10 | 0 | 10 |
| query | 13 | 0 | 13 |
| toc | 8 | 0 | 8 |
+| anchor | 6 | 0 | 6 |
| transform | 12 | 0 | 12 |
| normalize | 11 | 0 | 11 |
| find-replace | 10 | 0 | 10 |
@@ -35,4 +36,4 @@ _Generated by `lib/content/conformance.sh`_
| md-import | 38 | 0 | 38 |
| md-doc | 12 | 0 | 12 |
| fed | 20 | 0 | 20 |
-| **Total** | **615** | **0** | **615** |
+| **Total** | **621** | **0** | **621** |
diff --git a/lib/content/tests/anchor.sx b/lib/content/tests/anchor.sx
new file mode 100644
index 00000000..2fb2dc44
--- /dev/null
+++ b/lib/content/tests/anchor.sx
@@ -0,0 +1,58 @@
+;; Extension — anchored-heading HTML render (functional TOC links).
+
+(st-bootstrap-classes!)
+(content/bootstrap!)
+(content-bootstrap-section!)
+
+(define
+ d
+ (doc-append
+ (doc-append
+ (doc-append (doc-empty "d") (mk-heading "intro" 1 "Intro"))
+ (mk-text "p" "Body"))
+ (mk-section
+ "s"
+ (list (mk-heading "sub" 2 "Sub") (mk-text "n" "nested")))))
+
+;; ── headings get id anchors; other blocks unchanged ──
+(content-test
+ "anchored html"
+ (content/html-anchored d)
+ "
Intro
Body
")
+
+;; ── heading text escaped ──
+(content-test
+ "anchored escapes text"
+ (content/html-anchored
+ (doc-append (doc-empty "d") (mk-heading "h" 2 "A < B")))
+ "A < B
")
+
+;; ── non-heading-only doc identical to asHTML ──
+(define
+ np
+ (doc-append
+ (doc-append (doc-empty "d") (mk-text "p" "x"))
+ (mk-image "i" "/a.png" "alt")))
+(content-test "no headings == asHTML" (content/html-anchored np) (asHTML np))
+
+;; ── empty doc ──
+(content-test "anchored empty" (content/html-anchored (doc-empty "e")) "")
+
+;; ── anchors match TOC ids (end-to-end) ──
+(content-test
+ "anchor ids match toc"
+ (map (fn (h) (get h :id)) (content/headings d))
+ (list "intro" "sub"))
+
+;; ── deep nesting ──
+(define
+ deep
+ (doc-append
+ (doc-empty "d")
+ (mk-section
+ "o"
+ (list (mk-section "i" (list (mk-heading "deep" 3 "Deep")))))))
+(content-test
+ "deep anchored"
+ (content/html-anchored deep)
+ "")
diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md
index 33238787..b5209f93 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` → **615/615** (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 + transforms + find/replace, TOC rendering, normalization)
+`bash lib/content/conformance.sh` → **621/621** (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 + transforms + find/replace, TOC rendering + anchored headings, normalization)
## Ground rules
@@ -97,6 +97,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
- [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] TOC rendering (`toc.sx`: content/toc-markdown + toc-html from headings)
+- [x] anchored-heading render (`anchor.sx`: content/html-anchored, functional TOC links)
- [x] document normalization (`normalize.sx`: content/normalize, drop empty blocks/sections)
- [x] global find/replace (`find-replace.sx`: content/find-replace across text-bearing blocks)
- [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree)
@@ -104,6 +105,11 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
## Progress log
+- 2026-06-07 — Extension: anchored-heading render (`anchor.sx`).
+ `content/html-anchored` renders like asHTML but headings carry `id=""`
+ (tree-wide, sections recurse, text escaped), so the TOC's `#id` links resolve —
+ completing the TOC feature end-to-end. A separate render; plain asHTML
+ unchanged. 6 tests; suite 621/621.
- 2026-06-07 — Extension: global find/replace (`find-replace.sx`).
`content/find-replace` replaces every occurrence of a substring in the text
field of text/heading/code/quote blocks tree-wide (via the transform layer) —