diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh
index ada93017..b29e41e8 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 validate store snapshot crdt crdt-store sync md-import fed)
+SUITES=(block doc render api meta 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"
@@ -46,6 +46,7 @@ run_suite() {
(load "lib/content/text.sx")
(load "lib/content/section.sx")
(load "lib/content/stats.sx")
+(load "lib/content/table.sx")
(load "lib/content/markdown.sx")
(load "lib/content/validate.sx")
(load "lib/content/store.sx")
diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json
index 1c41185f..0c2a11c9 100644
--- a/lib/content/scoreboard.json
+++ b/lib/content/scoreboard.json
@@ -9,6 +9,7 @@
"text": {"pass": 20, "fail": 0},
"section": {"pass": 25, "fail": 0},
"stats": {"pass": 17, "fail": 0},
+ "table": {"pass": 15, "fail": 0},
"validate": {"pass": 23, "fail": 0},
"store": {"pass": 29, "fail": 0},
"snapshot": {"pass": 20, "fail": 0},
@@ -18,7 +19,7 @@
"md-import": {"pass": 24, "fail": 0},
"fed": {"pass": 20, "fail": 0}
},
- "total_pass": 433,
+ "total_pass": 448,
"total_fail": 0,
- "total": 433
+ "total": 448
}
diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md
index 17ab7aec..8430f924 100644
--- a/lib/content/scoreboard.md
+++ b/lib/content/scoreboard.md
@@ -13,6 +13,7 @@ _Generated by `lib/content/conformance.sh`_
| text | 20 | 0 | 20 |
| section | 25 | 0 | 25 |
| stats | 17 | 0 | 17 |
+| table | 15 | 0 | 15 |
| validate | 23 | 0 | 23 |
| store | 29 | 0 | 29 |
| snapshot | 20 | 0 | 20 |
@@ -21,4 +22,4 @@ _Generated by `lib/content/conformance.sh`_
| sync | 14 | 0 | 14 |
| md-import | 24 | 0 | 24 |
| fed | 20 | 0 | 20 |
-| **Total** | **433** | **0** | **433** |
+| **Total** | **448** | **0** | **448** |
diff --git a/lib/content/table.sx b/lib/content/table.sx
new file mode 100644
index 00000000..864a7036
--- /dev/null
+++ b/lib/content/table.sx
@@ -0,0 +1,54 @@
+;; content-on-sx — table block.
+;;
+;; CtTable holds `headers` (list of strings) and `rows` (list of string lists).
+;; Self-contained: it answers asHTML/asSx/asText/asMarkdown: by folding rows and
+;; cells, so it composes with the render boundary with no changes elsewhere. HTML
+;; cells are htmlEscaped, SX cells sxEscaped (render.sx must be loaded).
+;;
+;; Requires (loaded by harness): block.sx, doc.sx, render.sx (escapers);
+;; markdown.sx / text.sx for those formats.
+
+(define
+ content-bootstrap-table!
+ (fn
+ ()
+ (begin
+ (st-class-define! "CtTable" "CtBlock" (list "headers" "rows"))
+ (ct-def-method! "CtTable" "headers" "headers ^ headers")
+ (ct-def-method! "CtTable" "rows" "rows ^ rows")
+ (ct-def-method! "CtTable" "type" "type ^ #table")
+ (ct-def-method!
+ "CtTable"
+ "asHTML"
+ "asHTML | thead tbody | thead := '' , (headers inject: '' into: [:a :h | a , '| ' , h htmlEscaped , ' | ']) , '
'. tbody := '
' , (rows inject: '' into: [:a :r | a , '' , (r inject: '' into: [:b :c | b , '| ' , c htmlEscaped , ' | ']) , '
']) , ''. ^ ''")
+ (ct-def-method!
+ "CtTable"
+ "asSx"
+ "asSx ^ '(table (thead (tr ' , (headers inject: '' into: [:a :h | a , '(th \"' , h sxEscaped , '\")']) , ')) (tbody ' , (rows inject: '' into: [:a :r | a , '(tr ' , (r inject: '' into: [:b :c | b , '(td \"' , c sxEscaped , '\")']) , ')']) , '))'")
+ (ct-def-method!
+ "CtTable"
+ "asText"
+ "asText ^ (rows inject: (headers inject: '' into: [:a :h | (a = '' ifTrue: [h] ifFalse: [a , ' ' , h])]) into: [:acc :r | acc , ' ' , (r inject: '' into: [:b :c | (b = '' ifTrue: [c] ifFalse: [b , ' ' , c])])])")
+ (ct-def-method!
+ "CtTable"
+ "asMarkdown:"
+ "asMarkdown: nl | head sep body | head := '|' , (headers inject: '' into: [:a :h | a , ' ' , h , ' |']). sep := '|' , (headers inject: '' into: [:a :h | a , ' --- |']). body := (rows inject: '' into: [:acc :r | acc , nl , '|' , (r inject: '' into: [:a :c | a , ' ' , c , ' |'])]). ^ head , nl , sep , body")
+ true)))
+
+(define
+ mk-table
+ (fn
+ (id headers rows)
+ (st-iv-set!
+ (st-iv-set!
+ (st-iv-set! (st-make-instance "CtTable") "id" id)
+ "headers"
+ headers)
+ "rows"
+ rows)))
+
+(define
+ table?
+ (fn (b) (and (st-instance? b) (= (get b :class) "CtTable"))))
+(define table-headers (fn (tb) (st-send tb "headers" (list))))
+(define table-rows (fn (tb) (st-send tb "rows" (list))))
diff --git a/lib/content/tests/table.sx b/lib/content/tests/table.sx
new file mode 100644
index 00000000..5e00f6ef
--- /dev/null
+++ b/lib/content/tests/table.sx
@@ -0,0 +1,77 @@
+;; Extension — table block.
+
+(st-bootstrap-classes!)
+(content/bootstrap!)
+(content-bootstrap-markdown!)
+(content-bootstrap-text!)
+(content-bootstrap-table!)
+
+(define nl (str "\n"))
+
+(define
+ t
+ (mk-table
+ "t"
+ (list "Name" "Age")
+ (list (list "Ada" "36") (list "Al" "40"))))
+
+;; ── identity ──
+(content-test "table is block" (block? t) true)
+(content-test "table? yes" (table? t) true)
+(content-test "table type" (blk-type t) "table")
+(content-test "table headers" (table-headers t) (list "Name" "Age"))
+(content-test "table rows" (len (table-rows t)) 2)
+
+;; ── html ──
+(content-test
+ "table html"
+ (asHTML t)
+ "")
+(content-test
+ "table html escapes cells"
+ (asHTML (mk-table "t" (list "A| A<B |
|---|
| x&y |
")
+
+;; ── sx ──
+(content-test
+ "table sx"
+ (asSx t)
+ "(table (thead (tr (th \"Name\")(th \"Age\"))) (tbody (tr (td \"Ada\")(td \"36\"))(tr (td \"Al\")(td \"40\"))))")
+
+;; ── text ──
+(content-test "table text" (asText t) "Name Age Ada 36 Al 40")
+
+;; ── markdown ──
+(content-test
+ "table markdown"
+ (asMarkdown t)
+ (str "| Name | Age |" nl "| --- | --- |" nl "| Ada | 36 |" nl "| Al | 40 |"))
+
+;; ── in a document ──
+(define
+ d
+ (doc-append
+ (doc-append (doc-empty "d") (mk-heading "h" 1 "Data"))
+ t))
+(content-test
+ "doc with table html"
+ (asHTML d)
+ "Data
")
+(content-test "doc ids" (doc-ids d) (list "h" "t"))
+
+;; ── empty rows ──
+(content-test
+ "table no rows html"
+ (asHTML (mk-table "t" (list "H") (list)))
+ "")
+
+;; ── validation ──
+(content-test
+ "valid table"
+ (content/valid? (doc-append (doc-empty "d") t))
+ true)
+(content-test
+ "bad headers flagged"
+ (content/issue-kinds
+ (doc-append (doc-empty "d") (mk-table "t" "nope" (list))))
+ (list "field"))
diff --git a/lib/content/validate.sx b/lib/content/validate.sx
index 537053b6..65be8fc4 100644
--- a/lib/content/validate.sx
+++ b/lib/content/validate.sx
@@ -150,6 +150,16 @@
id
(list? (blk-get b "children"))
"section children must be a list"))
+ ((= t "table")
+ (append
+ (ct-field-issue
+ id
+ (list? (blk-get b "headers"))
+ "table headers must be a list")
+ (ct-field-issue
+ id
+ (list? (blk-get b "rows"))
+ "table rows must be a list")))
(else (list (ct-issue id "type" (str "unknown block type: " t))))))))
(define
diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md
index 38ee01e0..e2e33eb4 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` → **433/433** (Phases 1–4 COMPLETE + 11 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees, doc stats)
+`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)
## Ground rules
@@ -87,9 +87,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
- [x] plain-text render + excerpt (`text.sx`: asText, content/excerpt)
- [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)
## Progress log
+- 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
+ nested `inject:into:`. Self-contained (no edits to block.sx/render.sx);
+ `mk-table`, `table?`, `table-headers/rows`. validate.sx gained a `table` field
+ case (headers/rows must be lists). 15 tests; suite 448/448.
- 2026-06-07 — Extension: document statistics (`stats.sx`). `content/stats`
returns `{:words :chars :blocks :reading-minutes}`; word/char counts derive
from the tree-accurate `asText` projection, block count from an inline tree