From 0d93a9820fa09bf3d526c5182ea8fe064eb82d5d Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 00:03:05 +0000 Subject: [PATCH] content: render boundary (asHTML/asSx polymorphic) + 29 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/conformance.sh | 3 +- lib/content/render.sx | 78 +++++++++++++++++++++++++++++++++++++ lib/content/scoreboard.json | 7 ++-- lib/content/scoreboard.md | 3 +- lib/content/tests/render.sx | 73 ++++++++++++++++++++++++++++++++++ plans/content-on-sx.md | 11 +++++- 6 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 lib/content/render.sx create mode 100644 lib/content/tests/render.sx diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 84d92a01..60931794 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) +SUITES=(block doc render) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -35,6 +35,7 @@ run_suite() { (load "lib/smalltalk/eval.sx") (load "lib/content/block.sx") (load "lib/content/doc.sx") +(load "lib/content/render.sx") (epoch 2) (eval "(define content-test-pass 0)") (eval "(define content-test-fail 0)") diff --git a/lib/content/render.sx b/lib/content/render.sx new file mode 100644 index 00000000..e939ebb7 --- /dev/null +++ b/lib/content/render.sx @@ -0,0 +1,78 @@ +;; content-on-sx — render boundary. +;; +;; Rendering is a message, not a property switch: every block (and the document) +;; answers asHTML and asSx. The internal model carries no presentation — the +;; boundary format is chosen by which message you send. The document folds its +;; children's renderings, so (asHTML doc) / (asSx doc) are pure polymorphic +;; sends with no type dispatch in the SX layer. +;; +;; NOTE: no HTML escaping yet — text is emitted verbatim. Escaping is a boundary +;; concern to add before any untrusted content reaches render. + +(define + content-bootstrap-render! + (fn + () + (begin + (ct-def-method! + "CtHeading" + "asHTML" + "asHTML | t | t := level printString. ^ '' , text , ''") + (ct-def-method! "CtText" "asHTML" "asHTML ^ '

' , text , '

'") + (ct-def-method! + "CtCode" + "asHTML" + "asHTML ^ '
' , text , '
'") + (ct-def-method! + "CtQuote" + "asHTML" + "asHTML ^ '
' , text , '
'") + (ct-def-method! + "CtImage" + "asHTML" + "asHTML ^ '\"''") + (ct-def-method! + "CtEmbed" + "asHTML" + "asHTML ^ ''") + (ct-def-method! "CtDivider" "asHTML" "asHTML ^ '
'") + (ct-def-method! + "CtList" + "asHTML" + "asHTML | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '<' , tag , '>' , (items inject: '' into: [:a :x | a , '
  • ' , x , '
  • ']) , ''") + (ct-def-method! + "CtDoc" + "asHTML" + "asHTML ^ blocks inject: '' into: [:a :b | a , (b asHTML)]") + (ct-def-method! + "CtHeading" + "asSx" + "asSx | t | t := level printString. ^ '(h' , t , ' \"' , text , '\")'") + (ct-def-method! "CtText" "asSx" "asSx ^ '(p \"' , text , '\")'") + (ct-def-method! "CtCode" "asSx" "asSx ^ '(pre (code \"' , text , '\"))'") + (ct-def-method! "CtQuote" "asSx" "asSx ^ '(blockquote \"' , text , '\")'") + (ct-def-method! + "CtImage" + "asSx" + "asSx ^ '(img :src \"' , src , '\" :alt \"' , alt , '\")'") + (ct-def-method! "CtEmbed" "asSx" "asSx ^ '(iframe :src \"' , url , '\")'") + (ct-def-method! "CtDivider" "asSx" "asSx ^ '(hr)'") + (ct-def-method! + "CtList" + "asSx" + "asSx | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '(' , tag , ' ' , (items inject: '' into: [:a :x | a , '(li \"' , x , '\")']) , ')'") + (ct-def-method! + "CtDoc" + "asSx" + "asSx ^ '(article ' , (blocks inject: '' into: [:a :b | a , (b asSx)]) , ')'") + true))) + +;; ── SX boundary API — pure message sends ── +(define asHTML (fn (node) (str (st-send node "asHTML" (list))))) +(define asSx (fn (node) (str (st-send node "asSx" (list))))) + +;; readable aliases +(define render-html asHTML) +(define render-sx asSx) +(define block-html asHTML) +(define block-sx asSx) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index ae8afa03..f50e783e 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -1,9 +1,10 @@ { "suites": { "block": {"pass": 38, "fail": 0}, - "doc": {"pass": 40, "fail": 0} + "doc": {"pass": 40, "fail": 0}, + "render": {"pass": 29, "fail": 0} }, - "total_pass": 78, + "total_pass": 107, "total_fail": 0, - "total": 78 + "total": 107 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 3912f6bf..40e6e23a 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -6,4 +6,5 @@ _Generated by `lib/content/conformance.sh`_ |-------|-----:|-----:|------:| | block | 38 | 0 | 38 | | doc | 40 | 0 | 40 | -| **Total** | **78** | **0** | **78** | +| render | 29 | 0 | 29 | +| **Total** | **107** | **0** | **107** | diff --git a/lib/content/tests/render.sx b/lib/content/tests/render.sx new file mode 100644 index 00000000..f7ade85a --- /dev/null +++ b/lib/content/tests/render.sx @@ -0,0 +1,73 @@ +;; Phase 1 — render boundary. asHTML / asSx are polymorphic message sends on +;; blocks and the document. + +(st-bootstrap-classes!) +(content-bootstrap-blocks!) +(content-bootstrap-doc!) +(content-bootstrap-render!) + +(define h (mk-heading "h" 2 "Title")) +(define p (mk-text "p" "Hello")) +(define code (mk-code "c" "sx" "(+ 1 2)")) +(define q (mk-quote "q" "Ada" "to err")) +(define img (mk-image "i" "/c.png" "cat")) +(define em (mk-embed "e" "https://v/1" "vimeo")) +(define dv (mk-divider "d")) +(define ul (mk-list "u" false (list "a" "b"))) +(define ol (mk-list "o" true (list "x" "y"))) + +;; ── per-block asHTML ── +(content-test "heading html" (asHTML h) "

    Title

    ") +(content-test "text html" (asHTML p) "

    Hello

    ") +(content-test + "code html" + (asHTML code) + "
    (+ 1 2)
    ") +(content-test "quote html" (asHTML q) "
    to err
    ") +(content-test "image html" (asHTML img) "\"cat\"") +(content-test "embed html" (asHTML em) "") +(content-test "divider html" (asHTML dv) "
    ") +(content-test "ul html" (asHTML ul) "") +(content-test "ol html" (asHTML ol) "
    1. x
    2. y
    ") + +;; ── per-block asSx ── +(content-test "heading sx" (asSx h) "(h2 \"Title\")") +(content-test "text sx" (asSx p) "(p \"Hello\")") +(content-test "code sx" (asSx code) "(pre (code \"(+ 1 2)\"))") +(content-test "quote sx" (asSx q) "(blockquote \"to err\")") +(content-test "image sx" (asSx img) "(img :src \"/c.png\" :alt \"cat\")") +(content-test "embed sx" (asSx em) "(iframe :src \"https://v/1\")") +(content-test "divider sx" (asSx dv) "(hr)") +(content-test "ul sx" (asSx ul) "(ul (li \"a\")(li \"b\"))") +(content-test "ol sx" (asSx ol) "(ol (li \"x\")(li \"y\"))") + +;; ── document folds children (pure message dispatch) ── +(define d (doc-append (doc-append (doc-append (doc-empty "doc") h) p) dv)) +(content-test "doc html" (asHTML d) "

    Title

    Hello


    ") +(content-test "doc sx" (asSx d) "(article (h2 \"Title\")(p \"Hello\")(hr))") +(content-test "empty doc html" (asHTML (doc-empty "e")) "") +(content-test "empty doc sx" (asSx (doc-empty "e")) "(article )") + +;; ── render-* / block-* aliases ── +(content-test "render-html alias" (render-html d) (asHTML d)) +(content-test "render-sx alias" (render-sx d) (asSx d)) +(content-test "block-html alias" (block-html h) "

    Title

    ") + +;; ── render reflects edits (immutability: each render is of a version) ── +(define d2 (doc-update d "p" "text" "Edited")) +(content-test + "render after update" + (asHTML d2) + "

    Title

    Edited


    ") +(content-test + "original render unchanged" + (asHTML d) + "

    Title

    Hello


    ") +(content-test + "render after move" + (asHTML (doc-move d "h" 2)) + "

    Hello


    Title

    ") +(content-test + "render after delete" + (asHTML (doc-delete d "p")) + "

    Title


    ") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 9bf7ebbe..3024cc52 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` → **78/78** (Phase 1: blocks + doc) +`bash lib/content/conformance.sh` → **107/107** (Phase 1: blocks + doc + render) ## Ground rules @@ -59,7 +59,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Phase 1 — Block document model - [x] `block.sx` — typed block objects - [x] `doc.sx` — ordered tree, apply edit op, structural moves -- [ ] `render.sx` — block tree → HTML/SX +- [x] `render.sx` — block tree → HTML/SX - [ ] `api.sx` + tests + scoreboard + conformance.sh ## Phase 2 — Op log + versioning @@ -77,6 +77,13 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Phase 1 `render.sx`: render boundary as polymorphic message + dispatch. Every block and `CtDoc` answers `asHTML` / `asSx`; the document + folds children via Smalltalk `inject:into:` (works on raw SX lists), so + `(asHTML doc)` / `(asSx doc)` are pure sends with zero type-switching in SX. + Lists/headings render in Smalltalk source. No HTML escaping yet (noted in + render.sx — boundary concern before untrusted content). 29 tests; suite + 107/107. - 2026-06-06 — Phase 1 `doc.sx`: ordered block document (`CtDoc`) as a Smalltalk object holding an ordered block sequence. Edit ops are data dicts (`insert`/`update`/`move`/`delete`) with `op-*` constructors; `doc-apply` /