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

Sub

nested

") + +;; ── 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) + "

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) —