content: Phase 5 — rich inline text via structured runs (861/861)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
CtText.text may be a list of runs (text marks href); CtHeading/CtQuote rich, CtCode verbatim. New runs.sx overrides render/markdown/text methods (byte- identical for plain strings, opt-in). 4 modes: HTML tags / markdown / nested SX / plain asText (drift-proof). find-replace per-run marks-preserving; search across run boundaries; CRDT block-granularity LWW; data+wire round-trip. Runs are a Smalltalk-renderable list (not a dict — substrate can't read dict fields under nested render dispatch). +36 tests (44 suites). Phase 6 (char- level inline CRDT) recorded as future. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
|
||||
SUITES=(block doc render api meta page page-full markdown runs text section compose tree-edit move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
|
||||
|
||||
OUT_JSON="lib/content/scoreboard.json"
|
||||
OUT_MD="lib/content/scoreboard.md"
|
||||
@@ -69,6 +69,7 @@ run_suite() {
|
||||
(load "lib/content/page.sx")
|
||||
(load "lib/content/page-full.sx")
|
||||
(load "lib/content/markdown.sx")
|
||||
(load "lib/content/runs.sx")
|
||||
(load "lib/content/validate.sx")
|
||||
(load "lib/content/sanitize.sx")
|
||||
(load "lib/content/store.sx")
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
;; via content/find-replace and a word count over asText stay consistent.
|
||||
;; Immutable; case-sensitive.
|
||||
;;
|
||||
;; A text field may be a plain string OR a list of rich-text runs (Phase 5,
|
||||
;; run = (text marks href)). fr-rep-text rewrites per run, preserving each run's
|
||||
;; marks/href; a match that physically straddles two runs is not joined (the
|
||||
;; replacement would have no single mark set) — each run is rewritten in place.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks),
|
||||
;; table.sx (CtTable ivars).
|
||||
|
||||
@@ -24,6 +29,23 @@
|
||||
|
||||
(define fr-rep (fn (s from to) (replace (str s) from to)))
|
||||
|
||||
;; rewrite a text-bearing field that is either a plain string or a runs list
|
||||
(define
|
||||
fr-rep-text
|
||||
(fn
|
||||
(v from to)
|
||||
(if
|
||||
(list? v)
|
||||
(map
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(fr-rep (nth r 0) from to)
|
||||
(nth r 1)
|
||||
(nth r 2)))
|
||||
v)
|
||||
(fr-rep v from to))))
|
||||
|
||||
;; Blocks whose prose content find/replace rewrites (matches asText's set).
|
||||
(define
|
||||
fr-has-text?
|
||||
@@ -66,7 +88,7 @@
|
||||
(if (list? r) (map (fn (c) (fr-rep c from to)) r) r))
|
||||
rs))
|
||||
b1))))
|
||||
(else (blk-set b "text" (fr-rep (blk-get b "text") from to)))))))
|
||||
(else (blk-set b "text" (fr-rep-text (blk-get b "text") from to)))))))
|
||||
|
||||
(define
|
||||
content/find-replace
|
||||
|
||||
118
lib/content/runs.sx
Normal file
118
lib/content/runs.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
;; content-on-sx — Phase 5: rich inline text (structured runs).
|
||||
;;
|
||||
;; A CtText's `text` ivar may be EITHER a plain string (backward compat) OR a
|
||||
;; list of inline RUNS. A run is a 3-element list (text marks href):
|
||||
;; text — a string
|
||||
;; marks — a list of mark tokens, a subset of
|
||||
;; :bold :italic :underline :strikethrough :code :subscript
|
||||
;; :superscript :link (SX keywords evaluate to the strings the
|
||||
;; Smalltalk renderer compares against; build them with keywords)
|
||||
;; href — a string ("" when absent; the link target for a :link mark)
|
||||
;;
|
||||
;; Runs are a LIST, not a {:text :marks} dict, because rendering happens inside
|
||||
;; the Smalltalk render methods (nested blocks dispatch asHTML/etc. via Smalltalk
|
||||
;; message sends) and the Smalltalk-on-SX layer can iterate SX lists but cannot
|
||||
;; read SX dict fields. Lists are Smalltalk-native, render under nesting, and
|
||||
;; round-trip through data/wire for free.
|
||||
;;
|
||||
;; content-bootstrap-runs! OVERRIDES the render/markdown/text methods of CtText
|
||||
;; and its subclasses (CtHeading/CtQuote rich; CtCode verbatim — runs render as
|
||||
;; plain concatenated text) with run-aware versions that produce IDENTICAL output
|
||||
;; for a plain-string body. Opt-in: call after the render/markdown/text
|
||||
;; bootstraps; suites that don't call it are unaffected.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, render.sx, markdown.sx, text.sx.
|
||||
|
||||
;; ── SX-side run helpers ──
|
||||
(define mk-run (fn (text marks href) (list text marks href)))
|
||||
(define mk-run-plain (fn (text) (list text (list) "")))
|
||||
(define run-text (fn (r) (nth r 0)))
|
||||
(define run-marks (fn (r) (nth r 1)))
|
||||
(define run-href (fn (r) (nth r 2)))
|
||||
;; a CtText body is "rich" iff it is a runs list (vs a plain string)
|
||||
(define runs? (fn (v) (list? v)))
|
||||
;; build a CtText whose body is a list of runs
|
||||
(define
|
||||
mk-rich-text
|
||||
(fn (id runs) (st-iv-set! (mk-text id "") "text" runs)))
|
||||
|
||||
(define
|
||||
content-bootstrap-runs!
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runHtml:"
|
||||
"runHtml: run | frag marks href | frag := (run at: 1) htmlEscaped. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '<strong>' , frag , '</strong>']. (m = 'italic') ifTrue: [frag := '<em>' , frag , '</em>']. (m = 'underline') ifTrue: [frag := '<u>' , frag , '</u>']. (m = 'strikethrough') ifTrue: [frag := '<s>' , frag , '</s>']. (m = 'code') ifTrue: [frag := '<code>' , frag , '</code>']. (m = 'subscript') ifTrue: [frag := '<sub>' , frag , '</sub>']. (m = 'superscript') ifTrue: [frag := '<sup>' , frag , '</sup>']. (m = 'link') ifTrue: [frag := '<a href=\"' , href htmlEscaped , '\">' , frag , '</a>']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runSx:"
|
||||
"runSx: run | frag marks href | frag := '\"' , (run at: 1) sxEscaped , '\"'. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '(strong ' , frag , ')']. (m = 'italic') ifTrue: [frag := '(em ' , frag , ')']. (m = 'underline') ifTrue: [frag := '(u ' , frag , ')']. (m = 'strikethrough') ifTrue: [frag := '(s ' , frag , ')']. (m = 'code') ifTrue: [frag := '(code ' , frag , ')']. (m = 'subscript') ifTrue: [frag := '(sub ' , frag , ')']. (m = 'superscript') ifTrue: [frag := '(sup ' , frag , ')']. (m = 'link') ifTrue: [frag := '(a :href \"' , href sxEscaped , '\" ' , frag , ')']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runMd:"
|
||||
"runMd: run | frag marks href | frag := (run at: 1). marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '**' , frag , '**']. (m = 'italic') ifTrue: [frag := '_' , frag , '_']. (m = 'strikethrough') ifTrue: [frag := '~~' , frag , '~~']. (m = 'code') ifTrue: [frag := '`' , frag , '`']. (m = 'underline') ifTrue: [frag := '<u>' , frag , '</u>']. (m = 'subscript') ifTrue: [frag := '<sub>' , frag , '</sub>']. (m = 'superscript') ifTrue: [frag := '<sup>' , frag , '</sup>']. (m = 'link') ifTrue: [frag := '[' , frag , '](' , href , ')']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineHtml"
|
||||
"inlineHtml | out | (text class name = 'String') ifTrue: [^ text htmlEscaped]. out := ''. text do: [:run | out := out , (self runHtml: run)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineSx"
|
||||
"inlineSx | out | (text class name = 'String') ifTrue: [^ '\"' , text sxEscaped , '\"']. out := ''. text do: [:run | out := (out = '' ifTrue: [self runSx: run] ifFalse: [out , ' ' , (self runSx: run)])]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineMd"
|
||||
"inlineMd | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (self runMd: run)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineText"
|
||||
"inlineText | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (run at: 1)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"asHTML"
|
||||
"asHTML ^ '<p>' , self inlineHtml , '</p>'")
|
||||
(ct-def-method! "CtText" "asSx" "asSx ^ '(p ' , self inlineSx , ')'")
|
||||
(ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ self inlineMd")
|
||||
(ct-def-method! "CtText" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asHTML"
|
||||
"asHTML | t | t := level printString. ^ '<h' , t , '>' , self inlineHtml , '</h' , t , '>'")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asSx"
|
||||
"asSx | t | t := level printString. ^ '(h' , t , ' ' , self inlineSx , ')'")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl | h i | h := ''. i := 0. [i < level] whileTrue: [h := h , '#'. i := i + 1]. ^ h , ' ' , self inlineMd")
|
||||
(ct-def-method! "CtHeading" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asHTML"
|
||||
"asHTML ^ '<blockquote>' , self inlineHtml , '</blockquote>'")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asSx"
|
||||
"asSx ^ '(blockquote ' , self inlineSx , ')'")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl ^ '> ' , self inlineMd")
|
||||
(ct-def-method! "CtQuote" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asHTML"
|
||||
"asHTML ^ '<pre><code class=\"language-' , language htmlEscaped , '\">' , self inlineText htmlEscaped , '</code></pre>'")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asSx"
|
||||
"asSx ^ '(pre (code \"' , self inlineText sxEscaped , '\"))'")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl ^ '```' , language , nl , self inlineText , nl , '```'")
|
||||
(ct-def-method! "CtCode" "asText" "asText ^ self inlineText")
|
||||
true)))
|
||||
@@ -8,6 +8,7 @@
|
||||
"page": {"pass": 7, "fail": 0},
|
||||
"page-full": {"pass": 4, "fail": 0},
|
||||
"markdown": {"pass": 20, "fail": 0},
|
||||
"runs": {"pass": 36, "fail": 0},
|
||||
"text": {"pass": 20, "fail": 0},
|
||||
"section": {"pass": 25, "fail": 0},
|
||||
"compose": {"pass": 17, "fail": 0},
|
||||
@@ -44,7 +45,7 @@
|
||||
"md-doc": {"pass": 12, "fail": 0},
|
||||
"fed": {"pass": 20, "fail": 0}
|
||||
},
|
||||
"total_pass": 825,
|
||||
"total_pass": 861,
|
||||
"total_fail": 0,
|
||||
"total": 825
|
||||
"total": 861
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| page | 7 | 0 | 7 |
|
||||
| page-full | 4 | 0 | 4 |
|
||||
| markdown | 20 | 0 | 20 |
|
||||
| runs | 36 | 0 | 36 |
|
||||
| text | 20 | 0 | 20 |
|
||||
| section | 25 | 0 | 25 |
|
||||
| compose | 17 | 0 | 17 |
|
||||
@@ -47,4 +48,4 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| md-import | 38 | 0 | 38 |
|
||||
| md-doc | 12 | 0 | 12 |
|
||||
| fed | 20 | 0 | 20 |
|
||||
| **Total** | **825** | **0** | **825** |
|
||||
| **Total** | **861** | **0** | **861** |
|
||||
|
||||
227
lib/content/tests/runs.sx
Normal file
227
lib/content/tests/runs.sx
Normal file
@@ -0,0 +1,227 @@
|
||||
;; Phase 5 — rich inline text (structured runs). Acceptance suite.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-text!)
|
||||
(content-bootstrap-section!)
|
||||
(content-bootstrap-runs!)
|
||||
|
||||
;; one-run helper: a CtText with a single marked run
|
||||
(define
|
||||
one
|
||||
(fn (marks href) (mk-rich-text "p" (list (mk-run "x" marks href)))))
|
||||
|
||||
;; ── (1) four render modes ──
|
||||
;; a paragraph mixing plain + bold + a link
|
||||
(define
|
||||
rd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list
|
||||
(mk-run "the " (list) "")
|
||||
(mk-run "cat" (list :bold) "")
|
||||
(mk-run " and " (list) "")
|
||||
(mk-run "dog" (list :italic :link) "/d")
|
||||
(mk-run " sat" (list) "")))))
|
||||
(define p (doc-find rd "p"))
|
||||
(content-test
|
||||
"asHTML rich"
|
||||
(asHTML rd)
|
||||
"<p>the <strong>cat</strong> and <a href=\"/d\"><em>dog</em></a> sat</p>")
|
||||
(content-test
|
||||
"asMarkdown rich"
|
||||
(asMarkdown rd)
|
||||
"the **cat** and [_dog_](/d) sat")
|
||||
(content-test
|
||||
"asSx rich"
|
||||
(asSx rd)
|
||||
"(article (p \"the \" (strong \"cat\") \" and \" (a :href \"/d\" (em \"dog\")) \" sat\"))")
|
||||
(content-test
|
||||
"asText rich is plain (no markup)"
|
||||
(asText rd)
|
||||
"the cat and dog sat")
|
||||
|
||||
;; every mark renders in HTML
|
||||
(content-test
|
||||
"mark bold html"
|
||||
(asHTML (one (list :bold) ""))
|
||||
"<p><strong>x</strong></p>")
|
||||
(content-test
|
||||
"mark italic html"
|
||||
(asHTML (one (list :italic) ""))
|
||||
"<p><em>x</em></p>")
|
||||
(content-test
|
||||
"mark underline html"
|
||||
(asHTML (one (list :underline) ""))
|
||||
"<p><u>x</u></p>")
|
||||
(content-test
|
||||
"mark strike html"
|
||||
(asHTML (one (list :strikethrough) ""))
|
||||
"<p><s>x</s></p>")
|
||||
(content-test
|
||||
"mark code html"
|
||||
(asHTML (one (list :code) ""))
|
||||
"<p><code>x</code></p>")
|
||||
(content-test
|
||||
"mark sub html"
|
||||
(asHTML (one (list :subscript) ""))
|
||||
"<p><sub>x</sub></p>")
|
||||
(content-test
|
||||
"mark sup html"
|
||||
(asHTML (one (list :superscript) ""))
|
||||
"<p><sup>x</sup></p>")
|
||||
(content-test
|
||||
"mark link html"
|
||||
(asHTML (one (list :link) "/u"))
|
||||
"<p><a href=\"/u\">x</a></p>")
|
||||
|
||||
;; markdown marks
|
||||
(content-test "mark bold md" (asMarkdown (one (list :bold) "")) "**x**")
|
||||
(content-test "mark italic md" (asMarkdown (one (list :italic) "")) "_x_")
|
||||
(content-test
|
||||
"mark strike md"
|
||||
(asMarkdown (one (list :strikethrough) ""))
|
||||
"~~x~~")
|
||||
(content-test "mark code md" (asMarkdown (one (list :code) "")) "`x`")
|
||||
(content-test "mark link md" (asMarkdown (one (list :link) "/u")) "[x](/u)")
|
||||
(content-test
|
||||
"mark underline md fallback"
|
||||
(asMarkdown (one (list :underline) ""))
|
||||
"<u>x</u>")
|
||||
|
||||
;; nested marks (bold+italic) — deterministic nesting order
|
||||
(content-test
|
||||
"nested marks html"
|
||||
(asHTML (one (list :bold :italic) ""))
|
||||
"<p><em><strong>x</strong></em></p>")
|
||||
|
||||
;; escaping still happens inside runs
|
||||
(content-test
|
||||
"run text escaped html"
|
||||
(asHTML (mk-rich-text "p" (list (mk-run "a & b <c>" (list :bold) ""))))
|
||||
"<p><strong>a & b <c></strong></p>")
|
||||
|
||||
;; rich heading + quote + code
|
||||
(content-test
|
||||
"rich heading html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-heading "h" 2 "")
|
||||
"text"
|
||||
(list (mk-run "Big " (list) "") (mk-run "bold" (list :bold) ""))))
|
||||
"<h2>Big <strong>bold</strong></h2>")
|
||||
(content-test
|
||||
"rich quote html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-quote "q" "" "")
|
||||
"text"
|
||||
(list (mk-run "wise" (list :italic) ""))))
|
||||
"<blockquote><em>wise</em></blockquote>")
|
||||
;; code is verbatim — runs concatenate as plain text, marks ignored
|
||||
(content-test
|
||||
"code runs plain html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-code "c" "py" "")
|
||||
"text"
|
||||
(list (mk-run "a=" (list :bold) "") (mk-run "1" (list) ""))))
|
||||
"<pre><code class=\"language-py\">a=1</code></pre>")
|
||||
|
||||
;; ── (2) backward compat: plain-string CtText unchanged ──
|
||||
(content-test
|
||||
"plain html"
|
||||
(asHTML (mk-text "q" "hi & <b>"))
|
||||
"<p>hi & <b></p>")
|
||||
(content-test "plain sx" (asSx (mk-text "q" "hi")) "(p \"hi\")")
|
||||
(content-test "plain md" (asMarkdown (mk-text "q" "hi")) "hi")
|
||||
(content-test "plain text" (asText (mk-text "q" "hi")) "hi")
|
||||
(content-test
|
||||
"plain heading html"
|
||||
(asHTML (mk-heading "h" 3 "T"))
|
||||
"<h3>T</h3>")
|
||||
|
||||
;; ── (3) find-replace across runs (per-run, marks preserved) ──
|
||||
(define
|
||||
frd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list
|
||||
(mk-run "the Foo" (list :bold) "")
|
||||
(mk-run " and Foo here" (list) "")))))
|
||||
(define frr (content/find-replace frd "Foo" "Bar"))
|
||||
(content-test
|
||||
"find-replace rich plain text"
|
||||
(asText frr)
|
||||
"the Bar and Bar here")
|
||||
(content-test
|
||||
"find-replace rich preserves marks"
|
||||
(asHTML frr)
|
||||
"<p><strong>the Bar</strong> and Bar here</p>")
|
||||
(content-test
|
||||
"find-replace rich run0 still bold"
|
||||
(nth (nth (blk-get (doc-find frr "p") "text") 0) 1)
|
||||
(list "bold"))
|
||||
|
||||
;; ── (4) search-text via asText, across run boundary ──
|
||||
;; "cat sat" spans run1 ("the cat") and run2 (" sat")
|
||||
(define
|
||||
sd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list (mk-run "the cat" (list :bold) "") (mk-run " sat" (list) "")))))
|
||||
(content-test
|
||||
"search finds substring across runs"
|
||||
(content/search-text-ids sd "cat sat")
|
||||
(list "p"))
|
||||
(content-test "search miss" (content/search-text-ids sd "zzz") (list))
|
||||
|
||||
;; ── (5) CRDT invariant — runs are an opaque block-level value ──
|
||||
(define ra (list (mk-run "x" (list :bold) "")))
|
||||
(define rb (list (mk-run "y" (list :italic) "")))
|
||||
(define
|
||||
s1
|
||||
(crdt-insert
|
||||
(crdt-empty)
|
||||
"p"
|
||||
"text"
|
||||
(crdt-pos 5 "a")
|
||||
{:text ra}
|
||||
1
|
||||
"a"))
|
||||
(define s2 (crdt-update s1 "p" "text" rb 2 "b"))
|
||||
(content-test
|
||||
"crdt merge commutes with runs"
|
||||
(get (crdt-merge s1 s2) :elements)
|
||||
(get (crdt-merge s2 s1) :elements))
|
||||
(content-test
|
||||
"crdt merge idempotent with runs"
|
||||
(get (crdt-merge s2 s2) :elements)
|
||||
(get s2 :elements))
|
||||
;; LWW: later ts (rb, ts 2) wins; runs survive as the field value
|
||||
(content-test
|
||||
"crdt LWW keeps latest runs"
|
||||
(asHTML
|
||||
(crdt-element->block (get (get (crdt-merge s1 s2) :elements) "p")))
|
||||
"<p><em>y</em></p>")
|
||||
|
||||
;; ── (6) data + wire round-trip runs losslessly ──
|
||||
(content-test
|
||||
"data round-trip rich html"
|
||||
(asHTML (content/from-data (content/to-data rd)))
|
||||
(asHTML rd))
|
||||
(content-test
|
||||
"data round-trip rich text"
|
||||
(asText (content/from-data (content/to-data rd)))
|
||||
"the cat and dog sat")
|
||||
(content-test
|
||||
"wire round-trip rich html"
|
||||
(asHTML (content/from-wire (content/to-wire rd)))
|
||||
(asHTML rd))
|
||||
@@ -19,7 +19,7 @@ injected adapter, not core.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/content/conformance.sh` → **825/825** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, 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` → **861/861** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, 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
|
||||
|
||||
@@ -113,6 +113,52 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
||||
- [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree)
|
||||
- [x] wire serialization (`wire.sx`: content/to-wire + from-wire, SX-text on the wire)
|
||||
|
||||
## Phase 5 — rich inline text (structured runs)
|
||||
|
||||
Drives the rose-ash blog migration: lexical post bodies carry inline formatting
|
||||
(bold/italic/links) but `CtText` held one plain `text` string, so the canonical
|
||||
lexical→blocks conversion was lossy. Variant **(b)**: a `CtText`'s `text` may be
|
||||
EITHER a plain string (backward compat) OR a sequence of inline **runs**. Marks
|
||||
cover the lexical bitmask (bold=1 italic=2 strike=4 underline=8 code=16 sub=32
|
||||
sup=64) plus link nodes (which carry an href). Applies to `CtText` and its
|
||||
subclasses `CtHeading`/`CtQuote` (rich), and `CtCode` (verbatim — runs render as
|
||||
plain concatenated text, no marks).
|
||||
|
||||
**Run representation — a Smalltalk-renderable list, not a `{:text :marks}` dict.**
|
||||
A run is `(text marks href)`: `text` a string, `marks` a list of mark tokens
|
||||
(`:bold :italic :underline :strikethrough :code :subscript :superscript :link` —
|
||||
SX keywords evaluate to the strings the renderer matches), `href` a string (`""`
|
||||
when absent; carries the link target). *Why a list and not the dict the brief
|
||||
sketched:* rendering must happen inside the Smalltalk render methods (nested
|
||||
blocks dispatch `asHTML`/etc. through Smalltalk message sends), and the
|
||||
Smalltalk-on-SX layer can iterate SX lists (`do:`/`inject:into:`) but **cannot**
|
||||
read SX dict fields (`Dictionary>>at:` is broken in lib/smalltalk, which is out
|
||||
of scope). Lists are Smalltalk-native, render under nesting, and round-trip
|
||||
through data/wire for free (they're just nested lists+strings). The blog-side
|
||||
lexical→runs converter targets this `(text marks href)` shape.
|
||||
|
||||
Centralised in `runs.sx` (`content-bootstrap-runs!`) which OVERRIDES the
|
||||
render/markdown/text methods of CtText/CtHeading/CtQuote/CtCode with run-aware
|
||||
versions that fall through to identical output for plain strings — so it is
|
||||
opt-in (the blog enables it) and the existing suites, which don't bootstrap it,
|
||||
are untouched.
|
||||
|
||||
- [x] runs render in all four modes — asHTML `<strong>/<em>/<u>/<s>/<code>/<sub>/<sup>/<a href>`, asMarkdown `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup fall back to inline HTML), asSx emits nested run structure (`(p (strong "x") " y")`), asText returns the PLAIN concatenation (keeps search/stats/find-replace drift-proof)
|
||||
- [x] backward compat — a plain-string CtText still renders identically; existing suites stay green
|
||||
- [x] find-replace rewrites text across runs (per-run, marks preserved); runs join the text-bearing-field dispatch
|
||||
- [x] search-text finds substrings via asText, including across run boundaries
|
||||
- [x] CRDT invariant preserved — merge stays at BLOCK granularity (runs are the block's value): ops in any order / twice → identical document
|
||||
- [x] data + wire serialization round-trip runs losslessly
|
||||
|
||||
### Future — Phase 6 (NOT in scope now)
|
||||
|
||||
Variant **(c)**: character/run-level concurrent inline CRDT (Peritext/Yjs-style)
|
||||
so two authors can edit the same paragraph simultaneously — needed later for the
|
||||
multi-author SX editor that replaces Ghost. Block-granularity (b) is sufficient
|
||||
for the blog read-path migration. The lexical→runs converter itself lives on the
|
||||
blog/migration side (mark-set reference: `blog/bp/blog/ghost/lexical_to_sx.py`),
|
||||
not in lib/content.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Markdown table cells containing `|` do not round-trip.** `asMarkdown` on a
|
||||
@@ -137,6 +183,28 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — **Phase 5 — rich inline text (structured runs) DONE.** A CtText's
|
||||
`text` may now be a list of inline runs `(text marks href)` instead of a plain
|
||||
string; CtHeading/CtQuote inherit rich rendering, CtCode renders runs as plain
|
||||
verbatim text. New `runs.sx` (`content-bootstrap-runs!`) overrides the
|
||||
render/markdown/text methods of CtText & subclasses with run-aware versions
|
||||
that are byte-identical for plain-string bodies (opt-in; existing suites
|
||||
untouched). All 4 modes: asHTML emits `<strong>/<em>/<u>/<s>/<code>/<sub>/<sup>`
|
||||
+ `<a href>`, asMarkdown emits `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup →
|
||||
inline HTML), asSx emits nested run structure `(p "a" (strong "b"))` (matches
|
||||
the SX editor's wire format), asText returns the PLAIN concatenation — so
|
||||
search-text/stats/find-replace stay drift-proof. find-replace (`fr-rep-text`)
|
||||
rewrites per run with marks preserved; search-text finds across run boundaries
|
||||
via asText; CRDT merge treats the runs list as one block-level LWW value
|
||||
(commutes/idempotent, verified); data + wire round-trip runs losslessly.
|
||||
**Design note:** runs are a Smalltalk-renderable LIST, not the brief's
|
||||
`{:text :marks}` dict — the Smalltalk-on-SX render methods (which must run
|
||||
under nested dispatch) can iterate SX lists but cannot read SX dict fields
|
||||
(`Dictionary>>at:` is broken in lib/smalltalk, out of scope). Marks are built
|
||||
from `:bold`-style keywords (which evaluate to the strings the renderer
|
||||
matches). Phase 6 (char-level concurrent inline CRDT) recorded as future, not
|
||||
built. +36 runs tests (44 suites). 861/861.
|
||||
|
||||
- 2026-06-07 — Feature: `content/block-path` + `content/block-depth`
|
||||
(block-path.sx, new suite). The read-side companion to doc-find-deep (locate
|
||||
the block) and move-into/promote (relocate it): returns the ancestor-section
|
||||
|
||||
Reference in New Issue
Block a user