diff --git a/lib/content/api.sx b/lib/content/api.sx index d09899b6..6c9535b4 100644 --- a/lib/content/api.sx +++ b/lib/content/api.sx @@ -49,8 +49,8 @@ (if (content/op? ops) (doc-apply doc ops) (doc-apply-all doc ops)))) ;; ── render boundary ── -;; fmt is "html"/"sx"/"md" (or :html/:sx/:md — keywords evaluate to their name). -;; "md" needs markdown.sx loaded. +;; fmt is "html"/"sx"/"md"/"text" (or the matching keyword). "md" needs +;; markdown.sx loaded; "text" needs text.sx loaded. (define content/render (fn @@ -60,6 +60,7 @@ ((= fmt "sx") (asSx doc)) ((= fmt "md") (asMarkdown doc)) ((= fmt "markdown") (asMarkdown doc)) + ((= fmt "text") (asText 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 3549ad04..a25d508b 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 validate store snapshot crdt crdt-store sync md-import fed) +SUITES=(block doc render api meta markdown text validate store snapshot crdt crdt-store sync md-import fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -43,6 +43,7 @@ run_suite() { (load "lib/content/render.sx") (load "lib/content/api.sx") (load "lib/content/meta.sx") +(load "lib/content/text.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 1624aad3..167c48e6 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -6,6 +6,7 @@ "api": {"pass": 26, "fail": 0}, "meta": {"pass": 27, "fail": 0}, "markdown": {"pass": 20, "fail": 0}, + "text": {"pass": 20, "fail": 0}, "validate": {"pass": 17, "fail": 0}, "store": {"pass": 29, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, @@ -15,7 +16,7 @@ "md-import": {"pass": 24, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 365, + "total_pass": 385, "total_fail": 0, - "total": 365 + "total": 385 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 3d202d4f..2cfaa99d 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -10,6 +10,7 @@ _Generated by `lib/content/conformance.sh`_ | api | 26 | 0 | 26 | | meta | 27 | 0 | 27 | | markdown | 20 | 0 | 20 | +| text | 20 | 0 | 20 | | validate | 17 | 0 | 17 | | store | 29 | 0 | 29 | | snapshot | 20 | 0 | 20 | @@ -18,4 +19,4 @@ _Generated by `lib/content/conformance.sh`_ | sync | 14 | 0 | 14 | | md-import | 24 | 0 | 24 | | fed | 20 | 0 | 20 | -| **Total** | **365** | **0** | **365** | +| **Total** | **385** | **0** | **385** | diff --git a/lib/content/tests/text.sx b/lib/content/tests/text.sx new file mode 100644 index 00000000..078292ba --- /dev/null +++ b/lib/content/tests/text.sx @@ -0,0 +1,72 @@ +;; Extension — plain-text render mode + excerpts. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-text!) + +;; ── per-block ── +(content-test + "heading text" + (asText (mk-heading "h" 2 "Title")) + "Title") +(content-test "paragraph text" (asText (mk-text "p" "Body")) "Body") +(content-test "code text" (asText (mk-code "c" "sx" "(+ 1 2)")) "(+ 1 2)") +(content-test "quote text" (asText (mk-quote "q" "Ada" "to err")) "to err") +(content-test + "image -> alt" + (asText (mk-image "i" "/c.png" "a cat")) + "a cat") +(content-test + "embed -> empty" + (asText (mk-embed "e" "https://v" "vimeo")) + "") +(content-test "divider -> empty" (asText (mk-divider "d")) "") +(content-test + "list -> joined" + (asText (mk-list "l" false (list "a" "b" "c"))) + "a, b, c") +(content-test "empty list -> empty" (asText (mk-list "l" false (list))) "") + +;; ── document joins non-empty child texts with a space ── +(define + d + (doc-append + (doc-append + (doc-append + (doc-append (doc-empty "d") (mk-heading "h" 1 "Title")) + (mk-text "p" "Hello world")) + (mk-divider "dv")) + (mk-list "l" true (list "x" "y")))) +(content-test "doc text skips empties" (asText d) "Title Hello world x, y") +(content-test "empty doc text" (asText (doc-empty "e")) "") + +;; ── via facade ── +(content-test "render text" (content/render d "text") (asText d)) +(content-test "render text keyword" (content/render d :text) (asText d)) +(content-test "content/text alias" (content/text d) (asText d)) +(content-test "block-text alias" (block-text (mk-text "p" "x")) "x") + +;; ── excerpt ── +(content-test + "excerpt under limit" + (content/excerpt d 100) + "Title Hello world x, y") +(content-test "excerpt truncates" (content/excerpt d 5) "Title…") +(content-test + "excerpt exact length" + (content/excerpt + (doc-append (doc-empty "e") (mk-text "p" "12345")) + 5) + "12345") +(content-test + "excerpt one over" + (content/excerpt + (doc-append (doc-empty "e") (mk-text "p" "123456")) + 5) + "12345…") + +;; ── reflects edits ── +(content-test + "text after update" + (asText (doc-update d "p" "text" "Changed")) + "Title Changed x, y") diff --git a/lib/content/text.sx b/lib/content/text.sx new file mode 100644 index 00000000..aaad1128 --- /dev/null +++ b/lib/content/text.sx @@ -0,0 +1,46 @@ +;; content-on-sx — plain-text render mode + excerpts. +;; +;; A fourth boundary format via polymorphic dispatch: blocks answer asText, +;; stripping all markup. Useful for search indexing, meta descriptions and +;; previews. The document joins non-empty child texts with a single space. +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +(define + content-bootstrap-text! + (fn + () + (begin + (ct-def-method! "CtHeading" "asText" "asText ^ text") + (ct-def-method! "CtText" "asText" "asText ^ text") + (ct-def-method! "CtCode" "asText" "asText ^ text") + (ct-def-method! "CtQuote" "asText" "asText ^ text") + (ct-def-method! "CtImage" "asText" "asText ^ alt") + (ct-def-method! "CtEmbed" "asText" "asText ^ ''") + (ct-def-method! "CtDivider" "asText" "asText ^ ''") + (ct-def-method! + "CtList" + "asText" + "asText ^ (items inject: '' into: [:a :x | (a = '' ifTrue: [x] ifFalse: [a , ', ' , x])])") + (ct-def-method! + "CtDoc" + "asText" + "asText ^ (blocks inject: '' into: [:a :b | (b asText = '') ifTrue: [a] ifFalse: [(a = '' ifTrue: [b asText] ifFalse: [a , ' ' , b asText])]])") + true))) + +;; ── SX boundary ── +(define asText (fn (node) (str (st-send node "asText" (list))))) +(define content/text asText) +(define block-text asText) + +;; excerpt: first n chars of the plain text, with an ellipsis if truncated. +(define + content/excerpt + (fn + (doc n) + (let + ((t (asText doc))) + (if + (<= (string-length t) n) + t + (str (substring t 0 n) "…"))))) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 7833fc6d..b72c083b 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` → **365/365** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache, doc metadata) +`bash lib/content/conformance.sh` → **385/385** (Phases 1–4 COMPLETE + 9 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, validation, snapshot cache, doc metadata, plain-text render) ## Ground rules @@ -84,9 +84,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export) - [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent) - [x] document metadata (`meta.sx`: title/slug/tags + Ghost title plumbing) +- [x] plain-text render + excerpt (`text.sx`: asText, content/excerpt) ## Progress log +- 2026-06-07 — Extension: plain-text render + excerpts (`text.sx`). Fourth + boundary format via polymorphic `asText` (heading/text/code/quote→text, + image→alt, embed/divider→"", list→", "-joined); the document joins non-empty + child texts with a space. `content/render doc "text"`, `content/text`, + `content/excerpt doc n` (first n chars + "…" if truncated). For previews, + meta-descriptions, search indexing. 20 tests; suite 385/385. - 2026-06-07 — Extension: document metadata (`meta.sx`). CtDoc gained optional title/slug/tags ivars (declared in doc.sx, default nil/empty, no effect on block ops). Reads via message dispatch; copy-on-write setters