From c18545ea08b4efcdbcdcbe12cb0aae8f66f8b954 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 05:25:24 +0000 Subject: [PATCH] content: list-card summary projection (summary.sx) + 14 tests (697/697) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/conformance.sh | 3 +- lib/content/scoreboard.json | 5 ++- lib/content/scoreboard.md | 3 +- lib/content/summary.sx | 26 +++++++++++++ lib/content/tests/summary.sx | 74 ++++++++++++++++++++++++++++++++++++ plans/content-on-sx.md | 7 +++- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 lib/content/summary.sx create mode 100644 lib/content/tests/summary.sx diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 01ad9ba1..60bb9bc2 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 tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats table callout media data wire 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 tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary table callout media data wire validate store snapshot crdt crdt-store sync md-import md-doc fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -58,6 +58,7 @@ run_suite() { (load "lib/content/normalize.sx") (load "lib/content/find-replace.sx") (load "lib/content/stats.sx") +(load "lib/content/summary.sx") (load "lib/content/table.sx") (load "lib/content/callout.sx") (load "lib/content/media.sx") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 8b6a7f87..3cc0d400 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -23,6 +23,7 @@ "normalize": {"pass": 11, "fail": 0}, "find-replace": {"pass": 10, "fail": 0}, "stats": {"pass": 17, "fail": 0}, + "summary": {"pass": 14, "fail": 0}, "table": {"pass": 15, "fail": 0}, "callout": {"pass": 12, "fail": 0}, "media": {"pass": 15, "fail": 0}, @@ -38,7 +39,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 683, + "total_pass": 697, "total_fail": 0, - "total": 683 + "total": 697 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 3708c988..b46bb402 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -27,6 +27,7 @@ _Generated by `lib/content/conformance.sh`_ | normalize | 11 | 0 | 11 | | find-replace | 10 | 0 | 10 | | stats | 17 | 0 | 17 | +| summary | 14 | 0 | 14 | | table | 15 | 0 | 15 | | callout | 12 | 0 | 12 | | media | 15 | 0 | 15 | @@ -41,4 +42,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **683** | **0** | **683** | +| **Total** | **697** | **0** | **697** | diff --git a/lib/content/summary.sx b/lib/content/summary.sx new file mode 100644 index 00000000..1367618d --- /dev/null +++ b/lib/content/summary.sx @@ -0,0 +1,26 @@ +;; content-on-sx — list-card summary projection. +;; +;; content/summary returns a one-call projection for index/listing cards: +;; {:id :title :excerpt :words :reading-minutes :cover} +;; composing the metadata, text, stats and query layers. `cover` is the first +;; image's src (or nil). +;; +;; Requires (loaded by harness): doc.sx, meta.sx (doc-title), text.sx +;; (content/excerpt), stats.sx (word-count/reading), query.sx (select-type). + +(define + content/summary-title + (fn (doc) (let ((t (doc-title doc))) (if (= t nil) (doc-id doc) t)))) + +(define + content/cover + (fn + (doc) + (let + ((imgs (content/select-type doc "image"))) + (if + (= (len imgs) 0) + nil + (str (blk-get (first imgs) "src")))))) + +(define content/summary (fn (doc) {:id (doc-id doc) :reading-minutes (content/reading-minutes doc) :words (content/word-count doc) :title (content/summary-title doc) :excerpt (content/excerpt doc 160) :cover (content/cover doc)})) diff --git a/lib/content/tests/summary.sx b/lib/content/tests/summary.sx new file mode 100644 index 00000000..d4bd2a51 --- /dev/null +++ b/lib/content/tests/summary.sx @@ -0,0 +1,74 @@ +;; Extension — list-card summary projection. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-text!) + +(define + d + (doc-with-title + (doc-append + (doc-append + (doc-append (doc-empty "post") (mk-heading "h" 1 "Hello")) + (mk-text "p" "one two three four")) + (mk-image "img" "/cover.png" "cover")) + "My Post")) + +;; image alt ("cover") is part of the plain-text projection, so it counts. +(define s (content/summary d)) +(content-test "summary id" (get s :id) "post") +(content-test "summary title" (get s :title) "My Post") +(content-test + "summary excerpt" + (get s :excerpt) + "Hello one two three four cover") +(content-test "summary words" (get s :words) 6) +(content-test "summary reading" (get s :reading-minutes) 1) +(content-test "summary cover" (get s :cover) "/cover.png") + +;; ── title falls back to id ── +(content-test + "summary title fallback" + (get + (content/summary (doc-append (doc-empty "x") (mk-text "p" "y"))) + :title) + "x") + +;; ── no image → cover nil ── +(content-test + "no cover" + (get + (content/summary (doc-append (doc-empty "x") (mk-text "p" "y"))) + :cover) + nil) +(content-test "cover helper nil" (content/cover (doc-empty "e")) nil) + +;; ── first image wins as cover ── +(define + d2 + (doc-append + (doc-append (doc-empty "d") (mk-image "i1" "/a.png" "a")) + (mk-image "i2" "/b.png" "b"))) +(content-test "first image cover" (content/cover d2) "/a.png") + +;; ── empty doc ── +(define se (content/summary (doc-empty "e"))) +(content-test "empty summary words" (get se :words) 0) +(content-test "empty summary excerpt" (get se :excerpt) "") +(content-test "empty summary cover" (get se :cover) nil) + +;; ── excerpt truncates long content ── +(content-test + "excerpt truncated" + (> + (string-length + (get + (content/summary + (doc-append + (doc-empty "d") + (mk-text + "p" + "word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word"))) + :excerpt)) + 100) + true) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 1d01e0be..ad14f78b 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` → **683/683** (Phases 1–4 COMPLETE + ~31 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 + deep editing + flatten + relative reorder, doc stats, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) +`bash lib/content/conformance.sh` → **697/697** (Phases 1–4 COMPLETE + ~32 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 + deep editing + flatten + relative reorder, doc stats + summary, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) ## Ground rules @@ -91,6 +91,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] table block (`table.sx`: CtTable, renders html/sx/text/md, validated) - [x] callout block (`callout.sx`: CtCallout note/warning/tip, renders html/sx/text/md, validated) - [x] media block (`media.sx`: CtMedia video/audio, renders html/sx/text/md, validated) +- [x] list-card summary (`summary.sx`: content/summary — title/excerpt/words/reading/cover) - [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) @@ -110,6 +111,10 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Extension: list-card summary (`summary.sx`). `content/summary` + returns `{:id :title :excerpt :words :reading-minutes :cover}` for index/listing + cards, composing metadata + text + stats + query (`content/cover` = first + image's src). Title falls back to id. 14 tests; suite 697/697. - 2026-06-07 — Extension: video/audio media block (`media.sx`). `CtMedia` holds kind (video/audio) + src; answers asHTML (`