From 4fb4b04b21cc9f24f8583724f72a77b44105ac53 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 01:13:44 +0000 Subject: [PATCH] content: Markdown render mode (asMarkdown) + 20 tests (263/263) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/api.sx | 5 ++- lib/content/conformance.sh | 3 +- lib/content/markdown.sx | 55 ++++++++++++++++++++++++ lib/content/scoreboard.json | 5 ++- lib/content/scoreboard.md | 3 +- lib/content/tests/markdown.sx | 79 +++++++++++++++++++++++++++++++++++ plans/content-on-sx.md | 10 ++++- 7 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 lib/content/markdown.sx create mode 100644 lib/content/tests/markdown.sx diff --git a/lib/content/api.sx b/lib/content/api.sx index becac4ed..d09899b6 100644 --- a/lib/content/api.sx +++ b/lib/content/api.sx @@ -49,7 +49,8 @@ (if (content/op? ops) (doc-apply doc ops) (doc-apply-all doc ops)))) ;; ── render boundary ── -;; fmt is "html"/"sx" (or :html/:sx — keywords evaluate to their name). +;; fmt is "html"/"sx"/"md" (or :html/:sx/:md — keywords evaluate to their name). +;; "md" needs markdown.sx loaded. (define content/render (fn @@ -57,6 +58,8 @@ (cond ((= fmt "html") (asHTML doc)) ((= fmt "sx") (asSx doc)) + ((= fmt "md") (asMarkdown doc)) + ((= fmt "markdown") (asMarkdown doc)) (else (error (str "unknown render format: " fmt)))))) (define content/html asHTML) diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 249955da..5504dbbd 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 store crdt sync fed) +SUITES=(block doc render api markdown store crdt sync fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -42,6 +42,7 @@ run_suite() { (load "lib/content/doc.sx") (load "lib/content/render.sx") (load "lib/content/api.sx") +(load "lib/content/markdown.sx") (load "lib/content/store.sx") (load "lib/content/crdt.sx") (load "lib/content/sync.sx") diff --git a/lib/content/markdown.sx b/lib/content/markdown.sx new file mode 100644 index 00000000..1192def1 --- /dev/null +++ b/lib/content/markdown.sx @@ -0,0 +1,55 @@ +;; content-on-sx — Markdown render mode. +;; +;; A third boundary format alongside asHTML / asSx, via the same polymorphic +;; dispatch. The newline is supplied by the boundary as a keyword arg +;; (asMarkdown: nl) because this Smalltalk dialect has no Character newline +;; constructor — blocks that need internal newlines (code, lists, doc) use it. +;; +;; No Markdown escaping yet (Markdown's escaping rules differ from HTML); raw +;; text is emitted. Ordered lists emit "1." for every item (Markdown renumbers). +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +(define + content-bootstrap-markdown! + (fn + () + (begin + (ct-def-method! + "CtHeading" + "asMarkdown:" + "asMarkdown: nl | h i | h := ''. i := 0. [i < level] whileTrue: [h := h , '#'. i := i + 1]. ^ h , ' ' , text") + (ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ text") + (ct-def-method! + "CtCode" + "asMarkdown:" + "asMarkdown: nl ^ '```' , language , nl , text , nl , '```'") + (ct-def-method! "CtQuote" "asMarkdown:" "asMarkdown: nl ^ '> ' , text") + (ct-def-method! + "CtImage" + "asMarkdown:" + "asMarkdown: nl ^ '![' , alt , '](' , src , ')'") + (ct-def-method! + "CtEmbed" + "asMarkdown:" + "asMarkdown: nl ^ '[embed](' , url , ')'") + (ct-def-method! "CtDivider" "asMarkdown:" "asMarkdown: nl ^ '---'") + (ct-def-method! + "CtList" + "asMarkdown:" + "asMarkdown: nl | mark | mark := ordered ifTrue: ['1. '] ifFalse: ['- ']. ^ (items inject: '' into: [:a :x | a , (a = '' ifTrue: [''] ifFalse: [nl]) , mark , x])") + (ct-def-method! + "CtDoc" + "asMarkdown:" + "asMarkdown: nl ^ (blocks inject: '' into: [:a :b | a , (a = '' ifTrue: [''] ifFalse: [nl , nl]) , (b asMarkdown: nl)])") + true))) + +(define ct-nl (str "\n")) + +;; ── SX boundary ── +(define + asMarkdown + (fn (node) (str (st-send node "asMarkdown:" (list ct-nl))))) +(define content/markdown asMarkdown) +(define render-markdown asMarkdown) +(define block-markdown asMarkdown) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index a1204ca7..7b982ffd 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -4,12 +4,13 @@ "doc": {"pass": 40, "fail": 0}, "render": {"pass": 42, "fail": 0}, "api": {"pass": 26, "fail": 0}, + "markdown": {"pass": 20, "fail": 0}, "store": {"pass": 29, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "sync": {"pass": 14, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 243, + "total_pass": 263, "total_fail": 0, - "total": 243 + "total": 263 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index dbc3e56d..6ae10bb5 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -8,8 +8,9 @@ _Generated by `lib/content/conformance.sh`_ | doc | 40 | 0 | 40 | | render | 42 | 0 | 42 | | api | 26 | 0 | 26 | +| markdown | 20 | 0 | 20 | | store | 29 | 0 | 29 | | crdt | 34 | 0 | 34 | | sync | 14 | 0 | 14 | | fed | 20 | 0 | 20 | -| **Total** | **243** | **0** | **243** | +| **Total** | **263** | **0** | **263** | diff --git a/lib/content/tests/markdown.sx b/lib/content/tests/markdown.sx new file mode 100644 index 00000000..fa867c6d --- /dev/null +++ b/lib/content/tests/markdown.sx @@ -0,0 +1,79 @@ +;; Extension — Markdown render mode. asMarkdown is a polymorphic message send; +;; the boundary supplies the newline. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-markdown!) + +(define nl (str "\n")) + +;; ── per-block ── +(content-test + "heading h3" + (asMarkdown (mk-heading "h" 3 "Title")) + "### Title") +(content-test + "heading h1" + (asMarkdown (mk-heading "h" 1 "T")) + "# T") +(content-test "text md" (asMarkdown (mk-text "p" "body")) "body") +(content-test + "quote md" + (asMarkdown (mk-quote "q" "Ada" "to err")) + "> to err") +(content-test + "image md" + (asMarkdown (mk-image "i" "/c.png" "cat")) + "![cat](/c.png)") +(content-test + "embed md" + (asMarkdown (mk-embed "e" "https://v/1" "vimeo")) + "[embed](https://v/1)") +(content-test "divider md" (asMarkdown (mk-divider "d")) "---") +(content-test + "code md" + (asMarkdown (mk-code "c" "sx" "(+ 1 2)")) + (str "```sx" nl "(+ 1 2)" nl "```")) +(content-test + "ul md" + (asMarkdown (mk-list "u" false (list "a" "b" "c"))) + (str "- a" nl "- b" nl "- c")) +(content-test + "ol md" + (asMarkdown (mk-list "o" true (list "x" "y"))) + (str "1. x" nl "1. y")) +(content-test "empty list md" (asMarkdown (mk-list "e" false (list))) "") + +;; ── document joins blocks with a blank line ── +(define + d + (doc-append + (doc-append + (doc-append (doc-empty "doc") (mk-heading "h" 2 "Title")) + (mk-text "p" "Hello")) + (mk-divider "d"))) +(content-test + "doc md" + (asMarkdown d) + (str "## Title" nl nl "Hello" nl nl "---")) +(content-test "empty doc md" (asMarkdown (doc-empty "e")) "") + +;; ── via facade ── +(content-test "render md" (content/render d "md") (asMarkdown d)) +(content-test "render markdown" (content/render d "markdown") (asMarkdown d)) +(content-test "render md keyword" (content/render d :md) (asMarkdown d)) +(content-test "content/markdown alias" (content/markdown d) (asMarkdown d)) +(content-test + "block-markdown alias" + (block-markdown (mk-heading "h" 2 "X")) + "## X") + +;; ── reflects edits / immutability ── +(content-test + "md after update" + (asMarkdown (doc-update d "p" "text" "Edited")) + (str "## Title" nl nl "Edited" nl nl "---")) +(content-test + "md original unchanged" + (asMarkdown d) + (str "## Title" nl nl "Hello" nl nl "---")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 60fedc5d..429771d7 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` → **243/243** (Phases 1–4 COMPLETE + extensions: HTML + SX escaping) +`bash lib/content/conformance.sh` → **263/263** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render) ## Ground rules @@ -78,9 +78,17 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Extensions (post-roadmap) - [x] HTML escaping at the render boundary (`String>>htmlEscaped`: & < > ") - [x] asSx wire string-escaping (`String>>sxEscaped`: \ and " in SX literals) +- [x] Markdown render mode (`asMarkdown:` / `content/render doc "md"`) ## Progress log +- 2026-06-07 — Extension: Markdown render mode (`markdown.sx`). Third boundary + format alongside asHTML/asSx via the same polymorphic dispatch; blocks answer + `asMarkdown: nl` (boundary supplies the newline — this Smalltalk dialect has + no Character newline ctor). `content/render doc "md"`/`"markdown"`/`:md`, + `content/markdown`, `asMarkdown`. headings (`#`×level), fenced code, `> ` quote, + `![alt](src)`, `- `/`1. ` lists, `---`; doc joins blocks with a blank line. No + MD escaping yet. 20 tests; suite 263/263. - 2026-06-07 — Extension: asSx wire string-escaping. Added `String>>sxEscaped` (escapes `\`→`\\` then `"`→`\"`) and routed every `asSx` text/attr/list-item through it, so the SX wire format stays valid when content contains quotes or