diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh
index b29e41e8..7fc2e99e 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 markdown text section stats table validate store snapshot crdt crdt-store sync md-import fed)
+SUITES=(block doc render api meta page markdown text section stats table validate store snapshot crdt crdt-store sync md-import fed)
OUT_JSON="lib/content/scoreboard.json"
OUT_MD="lib/content/scoreboard.md"
@@ -47,6 +47,7 @@ run_suite() {
(load "lib/content/section.sx")
(load "lib/content/stats.sx")
(load "lib/content/table.sx")
+(load "lib/content/page.sx")
(load "lib/content/markdown.sx")
(load "lib/content/validate.sx")
(load "lib/content/store.sx")
diff --git a/lib/content/page.sx b/lib/content/page.sx
new file mode 100644
index 00000000..37a797a6
--- /dev/null
+++ b/lib/content/page.sx
@@ -0,0 +1,26 @@
+;; content-on-sx — full HTML page wrapper.
+;;
+;; content/page composes the metadata + render layers into the shippable
+;; artifact the blog serves: a minimal valid HTML5 document with an escaped
+;;
(from doc metadata, falling back to the id) and the rendered blocks
+;; as the body.
+;;
+;; Requires (loaded by harness): doc.sx, render.sx (asHTML + htmlEscaped),
+;; meta.sx (doc-title).
+
+(define ct-html-escape (fn (s) (str (st-send s "htmlEscaped" (list)))))
+
+(define
+ content/page-title
+ (fn (doc) (let ((t (doc-title doc))) (if (= t nil) (doc-id doc) t))))
+
+(define
+ content/page
+ (fn
+ (doc)
+ (str
+ ""
+ (ct-html-escape (content/page-title doc))
+ ""
+ (asHTML doc)
+ "")))
diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json
index 0c2a11c9..9419d345 100644
--- a/lib/content/scoreboard.json
+++ b/lib/content/scoreboard.json
@@ -5,6 +5,7 @@
"render": {"pass": 42, "fail": 0},
"api": {"pass": 26, "fail": 0},
"meta": {"pass": 27, "fail": 0},
+ "page": {"pass": 7, "fail": 0},
"markdown": {"pass": 20, "fail": 0},
"text": {"pass": 20, "fail": 0},
"section": {"pass": 25, "fail": 0},
@@ -19,7 +20,7 @@
"md-import": {"pass": 24, "fail": 0},
"fed": {"pass": 20, "fail": 0}
},
- "total_pass": 448,
+ "total_pass": 455,
"total_fail": 0,
- "total": 448
+ "total": 455
}
diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md
index 8430f924..ce7265bb 100644
--- a/lib/content/scoreboard.md
+++ b/lib/content/scoreboard.md
@@ -9,6 +9,7 @@ _Generated by `lib/content/conformance.sh`_
| render | 42 | 0 | 42 |
| api | 26 | 0 | 26 |
| meta | 27 | 0 | 27 |
+| page | 7 | 0 | 7 |
| markdown | 20 | 0 | 20 |
| text | 20 | 0 | 20 |
| section | 25 | 0 | 25 |
@@ -22,4 +23,4 @@ _Generated by `lib/content/conformance.sh`_
| sync | 14 | 0 | 14 |
| md-import | 24 | 0 | 24 |
| fed | 20 | 0 | 20 |
-| **Total** | **448** | **0** | **448** |
+| **Total** | **455** | **0** | **455** |
diff --git a/lib/content/tests/page.sx b/lib/content/tests/page.sx
new file mode 100644
index 00000000..fd89804b
--- /dev/null
+++ b/lib/content/tests/page.sx
@@ -0,0 +1,42 @@
+;; Extension — full HTML page wrapper.
+
+(st-bootstrap-classes!)
+(content/bootstrap!)
+
+(define
+ d
+ (doc-with-title
+ (doc-append (doc-empty "post") (mk-heading "h" 1 "Hi"))
+ "My Title"))
+
+(content-test
+ "page"
+ (content/page d)
+ "My TitleHi
")
+
+(content-test
+ "page title escaped"
+ (content/page (doc-with-title (doc-empty "x") "A < B"))
+ "A < B")
+
+(content-test
+ "page falls back to id"
+ (content/page (doc-empty "fallback"))
+ "fallback")
+
+(content-test "page-title from meta" (content/page-title d) "My Title")
+(content-test
+ "page-title fallback id"
+ (content/page-title (doc-empty "z"))
+ "z")
+
+(content-test
+ "page body reflects edits"
+ (content/page (doc-update d "h" "text" "Bye"))
+ "My TitleBye
")
+
+(content-test
+ "page multi-block body"
+ (content/page
+ (doc-append (doc-with-title (doc-empty "p") "T") (mk-text "x" "para")))
+ "Tpara
")
diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md
index e2e33eb4..f5da924d 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` → **448/448** (Phases 1–4 COMPLETE + 12 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees, doc stats, table block)
+`bash lib/content/conformance.sh` → **455/455** (Phases 1–4 COMPLETE + 13 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees, doc stats, table block, HTML page wrapper)
## Ground rules
@@ -88,9 +88,15 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
- [x] nested block trees (`section.sx`: CtSection container, recursive render, deep-find)
- [x] document statistics (`stats.sx`: word/char/block counts, reading time)
- [x] table block (`table.sx`: CtTable, renders html/sx/text/md, validated)
+- [x] HTML page wrapper (`page.sx`: content/page, escaped title from metadata)
## Progress log
+- 2026-06-07 — Extension: HTML page wrapper (`page.sx`). `content/page` composes
+ metadata + render into a minimal valid HTML5 document — escaped `` from
+ doc metadata (falling back to id) and the rendered blocks as the body.
+ `content/page-title`. The shippable artifact the blog serves. 7 tests; suite
+ 455/455.
- 2026-06-07 — Extension: table block (`table.sx`). `CtTable` holds headers +
rows (string lists); answers asHTML (escaped ``), asSx, asText, and
asMarkdown: (pipe table with dashed separator row) by folding rows×cells via