diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 1a7d54da..7158b62b 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 stats table 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 stats table data validate store snapshot crdt crdt-store sync md-import md-doc fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -48,6 +48,7 @@ run_suite() { (load "lib/content/compose.sx") (load "lib/content/stats.sx") (load "lib/content/table.sx") +(load "lib/content/data.sx") (load "lib/content/page.sx") (load "lib/content/page-full.sx") (load "lib/content/markdown.sx") diff --git a/lib/content/data.sx b/lib/content/data.sx new file mode 100644 index 00000000..86009304 --- /dev/null +++ b/lib/content/data.sx @@ -0,0 +1,79 @@ +;; content-on-sx — portable data serialization. +;; +;; Converts documents to/from a plain SX data form, decoupling storage and +;; transport from the Smalltalk instance shape. A document becomes +;; {:id :title :slug :tags :blocks (list block-data)} +;; and a block becomes {:id :type :fields {...}} (section children recurse). +;; content/from-data reconstructs real block objects. +;; +;; Requires (loaded by harness): block.sx, doc.sx, meta.sx, section.sx +;; (mk-section), table.sx (mk-table). + +;; ── to-data ── +(define + content/-fd-loop + (fn + (ks ivs acc) + (if + (= (len ks) 0) + acc + (let + ((k (first ks))) + (if + (= k "id") + (content/-fd-loop (rest ks) ivs acc) + (content/-fd-loop + (rest ks) + ivs + (assoc + acc + k + (if + (= k "children") + (map block->data (get ivs k)) + (get ivs k))))))))) + +(define block->data (fn (b) {:fields (content/-fd-loop (keys (get b :ivars)) (get b :ivars) {}) :id (blk-id b) :type (blk-type b)})) + +(define content/to-data (fn (doc) {:blocks (map block->data (doc-blocks doc)) :slug (doc-slug doc) :id (doc-id doc) :title (doc-title doc) :tags (doc-tags doc)})) + +;; ── from-data ── +(define + content/-field-pairs + (fn (fields) (map (fn (k) (list k (get fields k))) (keys fields)))) + +(define + data->block + (fn + (d) + (let + ((type (get d :type)) (id (get d :id)) (fields (get d :fields))) + (cond + ((= type "section") + (mk-section id (map data->block (get fields "children")))) + ((= type "table") + (mk-table id (get fields "headers") (get fields "rows"))) + (else (mk-block type id (content/-field-pairs fields))))))) + +(define + content/-meta-of + (fn + (data) + (let + ((m1 (if (= (get data :title) nil) {} (assoc {} :title (get data :title))))) + (let + ((m2 (if (= (get data :slug) nil) m1 (assoc m1 :slug (get data :slug))))) + (let + ((tags (get data :tags))) + (if + (or (= tags nil) (= (len tags) 0)) + m2 + (assoc m2 :tags tags))))))) + +(define + content/from-data + (fn + (data) + (doc-with-meta + (doc-new (get data :id) (map data->block (get data :blocks))) + (content/-meta-of data)))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 45b6981f..adee7c9d 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -13,6 +13,7 @@ "compose": {"pass": 17, "fail": 0}, "stats": {"pass": 17, "fail": 0}, "table": {"pass": 15, "fail": 0}, + "data": {"pass": 21, "fail": 0}, "validate": {"pass": 23, "fail": 0}, "store": {"pass": 29, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, @@ -23,7 +24,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 502, + "total_pass": 523, "total_fail": 0, - "total": 502 + "total": 523 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 1d2e416d..f144b600 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -17,6 +17,7 @@ _Generated by `lib/content/conformance.sh`_ | compose | 17 | 0 | 17 | | stats | 17 | 0 | 17 | | table | 15 | 0 | 15 | +| data | 21 | 0 | 21 | | validate | 23 | 0 | 23 | | store | 29 | 0 | 29 | | snapshot | 20 | 0 | 20 | @@ -26,4 +27,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **502** | **0** | **502** | +| **Total** | **523** | **0** | **523** | diff --git a/lib/content/tests/data.sx b/lib/content/tests/data.sx new file mode 100644 index 00000000..33eca9e5 --- /dev/null +++ b/lib/content/tests/data.sx @@ -0,0 +1,93 @@ +;; Extension — portable data serialization (to-data / from-data round-trip). + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-text!) +(content-bootstrap-markdown!) +(content-bootstrap-section!) +(content-bootstrap-table!) + +;; ── block->data shape ── +(define h (mk-heading "h" 2 "Hi")) +(content-test "block->data id" (get (block->data h) :id) "h") +(content-test "block->data type" (get (block->data h) :type) "heading") +(content-test "block->data fields" (get (block->data h) :fields) {:text "Hi" :level 2}) + +;; ── round-trip a mixed document with metadata ── +(define + d + (doc-with-meta + (doc-append + (doc-append + (doc-append + (doc-append (doc-empty "post") (mk-heading "h" 1 "Title")) + (mk-text "p" "Body")) + (mk-image "img" "/c.png" "cat")) + (mk-list "l" true (list "a" "b"))) + {:slug "s" :title "T" :tags (list "x" "y")})) + +(define rt (content/from-data (content/to-data d))) +(content-test "rt id" (doc-id rt) "post") +(content-test "rt title" (doc-title rt) "T") +(content-test "rt slug" (doc-slug rt) "s") +(content-test "rt tags" (doc-tags rt) (list "x" "y")) +(content-test "rt ids" (doc-ids rt) (list "h" "p" "img" "l")) +(content-test "rt render" (asHTML rt) (asHTML d)) +(content-test + "rt heading level" + (blk-send (doc-find rt "h") "level") + 1) +(content-test + "rt list items" + (blk-send (doc-find rt "l") "items") + (list "a" "b")) + +;; ── nested sections round-trip ── +(define + ds + (doc-append + (doc-empty "d") + (mk-section + "s" + (list + (mk-heading "nh" 2 "N") + (mk-section "i" (list (mk-text "x" "deep"))))))) +(define rts (content/from-data (content/to-data ds))) +(content-test "rt nested render" (asHTML rts) (asHTML ds)) +(content-test "rt nested tree-ids" (doc-tree-ids rts) (doc-tree-ids ds)) +(content-test + "rt nested deep-find" + (str (blk-send (doc-deep-find rts "x") "text")) + "deep") + +;; ── table round-trip ── +(define + dtb + (doc-append + (doc-empty "d") + (mk-table "t" (list "A" "B") (list (list "1" "2"))))) +(define rtt (content/from-data (content/to-data dtb))) +(content-test "rt table render" (asHTML rtt) (asHTML dtb)) +(content-test + "rt table headers" + (table-headers (doc-find rtt "t")) + (list "A" "B")) + +;; ── data is plain (no st-instance markers at top level) ── +(define dat (content/to-data d)) +(content-test "data id field" (get dat :id) "post") +(content-test "data block count" (len (get dat :blocks)) 4) +(content-test + "data first block type" + (get (first (get dat :blocks)) :type) + "heading") + +;; ── empty doc round-trip ── +(content-test + "rt empty ids" + (doc-ids (content/from-data (content/to-data (doc-empty "e")))) + (list)) +(content-test + "rt no-meta title nil" + (doc-title (content/from-data (content/to-data (doc-empty "e")))) + nil) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 2e512e88..49326b10 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` → **502/502** (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, doc stats, table block, HTML page wrapper + SEO page, doc composition) +`bash lib/content/conformance.sh` → **523/523** (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, doc stats, table block, HTML page wrapper + SEO page, doc composition, portable data serialization) ## Ground rules @@ -92,9 +92,17 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] HTML page wrapper (`page.sx`: content/page, escaped title from metadata) - [x] SEO page (`page-full.sx`: content/page-full, lang + meta description from excerpt) - [x] document composition (`compose.sx`: concat/prepend/concat-all/wrap-section) +- [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree) ## Progress log +- 2026-06-07 — Extension: portable data serialization (`data.sx`). + `content/to-data` converts a document to plain SX data + (`{:id :title :slug :tags :blocks [{:id :type :fields}]}`, sections recursing); + `content/from-data` reconstructs real block objects (section/table handled + specially, others generically via mk-block). Round-trips the full tree + + metadata (render-equal), decoupling storage/transport from the Smalltalk + instance shape. 21 tests; suite 523/523. - 2026-06-07 — Extension: document composition (`compose.sx`). `content/concat` / `content/prepend` / `content/concat-all` combine documents (keeping the first's id + metadata, concatenating blocks, immutable); `content/wrap-section`