eleven / ocho Public

ios: the composer serializer twin — model, wire bytes, rules (#890) (#916)

The inert foundation for native WYSIWYG attempt two: ComposerModel
(lines/blocks/styled runs with the marked-break distinction),
ComposerSerialize (a byte-for-byte twin of the web's composer-md.js —
zero-escape, mark rank em→strong→strike, mixable continuation, break-
mark filter, expelled whitespace, tight lists, sized code delimiters),
and ComposerRules (the format-as-you-type regexes, drift-gated against
composer-pm.js like the emoji map is against emoji.js).

The contract: test/event-vectors/composer-serialize.json — 44 vectors
generated FROM the web serializer (the authority), asserted four ways
(web serialize + reparse via test/composer-vectors.test.mjs; native
serialize + reparse via ComposerSerializeTests, whose reparse chains
into the existing markdownTree vector gate). Plus the edit-identity
fuzz (serializeComposer(plainModel(s)) == s — the literal edit-load
guarantee) and a 100KB perf pin against attempt one's paste wedge.

No UI touched. (FlowEngineTests fails identically on main — pre-existing.)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 31c09dd7887e799bfc34deed77026d7cbff0a4b1 parent 98282bf
9 files changed, +3550 −1
  • ios/LChatCore/Sources/LChatCore/ComposerModel.swift +173 −0
  • ios/LChatCore/Sources/LChatCore/ComposerRules.swift +67 −0
  • ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift +192 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift +88 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerSerializeTests.swift +218 −0
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json +1362 −0
  • package.json +1 −1
  • test/composer-vectors.test.mjs +87 −0
  • test/event-vectors/composer-serialize.json +1362 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerModel.swift b/ios/LChatCore/Sources/LChatCore/ComposerModel.swift
new file mode 100644
index 0000000..9972677
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ComposerModel.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+// The native composer's document model (issue #890) — the twin of the web
+// composer's ProseMirror doc, flattened to what a TextKit editor can derive
+// in one linear scan: lines with a block kind and styled runs. Pure
+// Foundation; every rule here is headless-testable (ComposerModelTests), and
+// the serializer over it (ComposerSerialize.swift) is pinned byte-for-byte to
+// the web by Fixtures/composer-serialize-vector.json.
+//
+// Scope (stated in the vector file too): no nested blocks, no links, no
+// breaks inside list items — unreachable from composer typing on both
+// platforms (web binds neither sinkListItem nor Tab; native edit-load is
+// literal and paste is plain).
+
+// MARK: - Inline
+
+public struct InlineStyle: Equatable, Hashable {
+ public var bold: Bool
+ public var italic: Bool
+ public var strike: Bool
+ public var code: Bool // exclusive: the editor never combines code with others
+
+ public init(bold: Bool = false, italic: Bool = false, strike: Bool = false, code: Bool = false) {
+ self.bold = bold
+ self.italic = italic
+ self.strike = strike
+ self.code = code
+ }
+
+ public static let plain = InlineStyle()
+ public var isPlain: Bool { !bold && !italic && !strike && !code }
+}
+
+public struct RichRun: Equatable {
+ public var text: String // never contains "\n"
+ public var style: InlineStyle
+
+ public init(_ text: String, style: InlineStyle = .plain) {
+ self.text = text
+ self.style = style
+ }
+}
+
+// A paragraph/quote's inline content: styled runs and hard breaks. A break
+// carries the style of the "\n" itself — the web's marked-break rule: typed
+// Shift+Enter inserts a PLAIN break (**a**\n**b**); a select-across-lines
+// style toggle styles the break too (**a\nb**).
+public enum InlineNode: Equatable {
+ case run(RichRun)
+ case brk(InlineStyle)
+}
+
+// MARK: - Lines (what the editor derives from NSTextStorage)
+
+public enum LineKind: Equatable {
+ case paragraph
+ case bullet
+ case ordered(Int) // the displayed number; grouping normalizes to the run's first
+ case quote
+ case fence(lang: String) // every line of a fence carries this; lang from the first
+}
+
+public struct ComposerLine: Equatable {
+ public var kind: LineKind
+ public var runs: [RichRun]
+ public var joinStyle: InlineStyle // marks on this line's trailing "\n"
+
+ public init(kind: LineKind = .paragraph, runs: [RichRun] = [], joinStyle: InlineStyle = .plain) {
+ self.kind = kind
+ self.runs = runs
+ self.joinStyle = joinStyle
+ }
+}
+
+// MARK: - Blocks (what the serializer consumes)
+
+public enum ComposerBlock: Equatable {
+ case paragraph([InlineNode])
+ case bullet(items: [[RichRun]]) // flat, single-line items
+ case ordered(start: Int, items: [[RichRun]])
+ case quote([InlineNode]) // one paragraph of inlines
+ case fence(lang: String, text: String) // verbatim, lines joined "\n"
+}
+
+// Group maximal same-kind line runs into blocks — the deterministic bridge
+// from storage-derived lines to the serializer's input.
+public func composerBlocks(_ lines: [ComposerLine]) -> [ComposerBlock] {
+ var out: [ComposerBlock] = []
+ var i = 0
+ func inlines(_ group: [ComposerLine]) -> [InlineNode] {
+ var nodes: [InlineNode] = []
+ for (j, line) in group.enumerated() {
+ nodes.append(contentsOf: line.runs.map { .run($0) })
+ if j < group.count - 1 { nodes.append(.brk(line.joinStyle)) }
+ }
+ return nodes
+ }
+ while i < lines.count {
+ let kind = lines[i].kind
+ var j = i
+ func sameGroup(_ k: LineKind) -> Bool {
+ switch (kind, k) {
+ case (.paragraph, .paragraph), (.bullet, .bullet), (.quote, .quote), (.ordered, .ordered):
+ return true
+ case (.fence, .fence):
+ return true // one fence per contiguous run; lang comes from its first line
+ default:
+ return false
+ }
+ }
+ while j < lines.count, sameGroup(lines[j].kind) { j += 1 }
+ let group = Array(lines[i..<j])
+ switch kind {
+ case .paragraph:
+ out.append(.paragraph(inlines(group)))
+ case .quote:
+ out.append(.quote(inlines(group)))
+ case .bullet:
+ out.append(.bullet(items: group.map { $0.runs }))
+ case .ordered(let start):
+ out.append(.ordered(start: start, items: group.map { $0.runs }))
+ case .fence(let lang):
+ out.append(.fence(lang: lang, text: group.map { line in line.runs.map(\.text).joined() }.joined(separator: "\n")))
+ }
+ i = j
+ }
+ return out
+}
+
+// MARK: - External sets (edit-load / clear / pushback)
+
+// The ONLY way text enters the model from outside: literal plain content.
+// Never infers block kinds or marks — this is what guarantees byte-identical
+// edit round-trips (serializeComposer(plainModel(s)) == s, fuzz-tested).
+public func plainModel(_ text: String) -> [ComposerBlock] {
+ var nodes: [InlineNode] = []
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
+ for (i, line) in lines.enumerated() {
+ if i > 0 { nodes.append(.brk(.plain)) }
+ if !line.isEmpty { nodes.append(.run(RichRun(String(line)))) }
+ }
+ return [.paragraph(nodes)]
+}
+
+// MARK: - Display text (the typeahead space)
+
+// The storage-string twin: what the editor's content-only NSTextStorage holds
+// — blocks joined by "\n", breaks as "\n", no markers, no block prefixes.
+// This equals the web composer's docText, so composerToken keeps running over
+// the raw field text with today's exact caret handling.
+public func displayText(_ blocks: [ComposerBlock]) -> String {
+ var parts: [String] = []
+ for block in blocks {
+ switch block {
+ case .paragraph(let nodes), .quote(let nodes):
+ var s = ""
+ for node in nodes {
+ switch node {
+ case .run(let run): s += run.text
+ case .brk: s += "\n"
+ }
+ }
+ parts.append(s)
+ case .bullet(let items):
+ parts.append(items.map { $0.map(\.text).joined() }.joined(separator: "\n"))
+ case .ordered(_, let items):
+ parts.append(items.map { $0.map(\.text).joined() }.joined(separator: "\n"))
+ case .fence(_, let text):
+ parts.append(text)
+ }
+ }
+ return parts.joined(separator: "\n")
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerRules.swift b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift
new file mode 100644
index 0000000..27467c8
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+// The format-as-you-type brain (issue #890) — the native twin of the input
+// rules in web/static/composer-pm.js. Pure string-in, decision-out, so the
+// regex semantics are headless-testable; the editors (ComposerField /
+// MacComposerField) own WHEN to consult it (single typed characters only,
+// never during IME composition, never inside code) and how to apply the edit.
+//
+// The regex literals are transliterated from composer-pm.js and drift-gated:
+// ComposerRulesTests greps them out of the web file (the ComposerTypeaheadTests
+// pattern), so a web-side rule change fails the native build until mirrored.
+
+public enum RuleMark: Equatable {
+ case bold, italic, code, strike
+}
+
+public struct InlineCompletion: Equatable {
+ public let replaceRange: Range<Int> // UTF-16 offsets, relative to the given line
+ public let content: String
+ public let mark: RuleMark
+}
+
+public enum BlockTrigger: Equatable {
+ case bullet
+ case ordered(start: Int)
+ case quote
+ case fence
+}
+
+// Order matters: strong before em, so the ** closer never half-fires * (the
+// composer-pm.js comment, kept).
+private let inlineRules: [(NSRegularExpression, RuleMark)] = [
+ (try! NSRegularExpression(pattern: "\\*\\*([^*\\s](?:[^*]*[^*\\s])?)\\*\\*$"), .bold),
+ (try! NSRegularExpression(pattern: "(?<!\\*)\\*([^*\\s](?:[^*]*[^*\\s])?)\\*$"), .italic),
+ (try! NSRegularExpression(pattern: "`([^`\\s](?:[^`]*[^`\\s])?)`$"), .code),
+ (try! NSRegularExpression(pattern: "(?<!~)~([^~\\s](?:[^~]*[^~\\s])?)~$"), .strike),
+]
+
+// The text from the start of the caret's line up to the caret. Returns the
+// completion to apply (replace the marker span with styled content) or nil.
+public func inlineCompletion(lineUpToCaret line: String) -> InlineCompletion? {
+ let ns = line as NSString
+ let full = NSRange(location: 0, length: ns.length)
+ for (re, mark) in inlineRules {
+ guard let m = re.firstMatch(in: line, range: full), m.range.location != NSNotFound else { continue }
+ let content = ns.substring(with: m.range(at: 1))
+ return InlineCompletion(replaceRange: m.range.location..<(m.range.location + m.range.length), content: content, mark: mark)
+ }
+ return nil
+}
+
+private let bulletRE = try! NSRegularExpression(pattern: "^\\s*([-*])\\s$")
+private let orderedRE = try! NSRegularExpression(pattern: "^(\\d+)\\.\\s$")
+private let quoteRE = try! NSRegularExpression(pattern: "^\\s*>\\s$")
+private let fenceRE = try! NSRegularExpression(pattern: "^```$")
+
+public func blockTrigger(lineUpToCaret line: String) -> BlockTrigger? {
+ let ns = line as NSString
+ let full = NSRange(location: 0, length: ns.length)
+ if bulletRE.firstMatch(in: line, range: full) != nil { return .bullet }
+ if let m = orderedRE.firstMatch(in: line, range: full) {
+ return .ordered(start: Int(ns.substring(with: m.range(at: 1))) ?? 1)
+ }
+ if quoteRE.firstMatch(in: line, range: full) != nil { return .quote }
+ if fenceRE.firstMatch(in: line, range: full) != nil { return .fence }
+ return nil
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift
new file mode 100644
index 0000000..149e6ef
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift
@@ -0,0 +1,192 @@
+import Foundation
+
+// The wire serializer for the native composer model (issue #890) — a
+// byte-for-byte twin of the web's composer-md.js serializeDoc (a specialized
+// port of prosemirror-markdown's renderInline state machine). Every rule here
+// is pinned by Fixtures/composer-serialize-vector.json, generated FROM the
+// web serializer: when the twins disagree, fix this file, never the vectors.
+//
+// The rules that matter:
+// - Zero-escape plain text (never a backslash — old clients render them
+// literally); escapes only inside marked runs (* in em/strong, ~ in strike).
+// - Mark rank em → strong → strike, outermost-first (***~x~***); marks
+// already open stay open across style changes (mixable continuation:
+// **bold *inner* bold**).
+// - A break's marks survive only if the mark continues onto non-whitespace
+// text (**a\nb** from a styled break vs **a**\n**b** from a typed one).
+// - expelEnclosingWhitespace: whitespace at a mark boundary moves outside
+// the markers; a whitespace-only styled run collapses to bare whitespace.
+// - Code spans size delimiters past inner backticks and space-pad only when
+// the content contains a backtick. Strike is single ~. Lists are tight.
+
+// MARK: - Marks
+
+private enum Mark: Int, CaseIterable {
+ case em, strong, strike // rank order: lower = outermost
+
+ var delimiter: String {
+ switch self {
+ case .em: return "*"
+ case .strong: return "**"
+ case .strike: return "~"
+ }
+ }
+}
+
+private func marks(of style: InlineStyle) -> [Mark] {
+ var out: [Mark] = []
+ if style.italic { out.append(.em) }
+ if style.bold { out.append(.strong) }
+ if style.strike { out.append(.strike) }
+ return out
+}
+
+private func escaped(_ text: String, style: InlineStyle) -> String {
+ var out = text
+ if style.bold || style.italic { out = out.replacingOccurrences(of: "*", with: "\\*") }
+ if style.strike { out = out.replacingOccurrences(of: "~", with: "\\~") }
+ return out
+}
+
+private func isWhitespace(_ s: String) -> Bool {
+ !s.isEmpty && s.allSatisfy { $0 == " " || $0 == "\t" }
+}
+
+// MARK: - Inline state machine
+
+private func renderInlines(_ nodes: [InlineNode]) -> String {
+ // Pass 1 — break-mark filter: a break keeps a mark only if the mark
+ // continues on the next node and that node is non-whitespace text.
+ var filtered: [InlineNode] = []
+ for (i, node) in nodes.enumerated() {
+ guard case .brk(let style) = node else {
+ filtered.append(node)
+ continue
+ }
+ var kept = InlineStyle.plain
+ if i + 1 < nodes.count, case .run(let next) = nodes[i + 1], !next.text.isEmpty, !isWhitespace(next.text) {
+ kept.bold = style.bold && next.style.bold
+ kept.italic = style.italic && next.style.italic
+ kept.strike = style.strike && next.style.strike
+ }
+ filtered.append(.brk(kept))
+ }
+
+ // Pass 2 — expel enclosing whitespace: a styled run's leading/trailing
+ // whitespace moves outside any mark that opens/closes at that boundary
+ // (the expelled piece keeps the marks shared with its neighbour).
+ var expelled: [InlineNode] = []
+ func styleOf(_ node: InlineNode?) -> InlineStyle {
+ switch node {
+ case .run(let r): return r.style
+ case .brk(let s): return s
+ case nil: return .plain
+ }
+ }
+ func intersection(_ a: InlineStyle, _ b: InlineStyle) -> InlineStyle {
+ InlineStyle(bold: a.bold && b.bold, italic: a.italic && b.italic, strike: a.strike && b.strike, code: a.code && b.code)
+ }
+ for (i, node) in filtered.enumerated() {
+ guard case .run(let run) = node, !run.style.isPlain, !run.style.code else {
+ expelled.append(node)
+ continue
+ }
+ var core = Substring(run.text)
+ let lead = String(core.prefix(while: { $0 == " " || $0 == "\t" }))
+ core = core.dropFirst(lead.count)
+ var trailCount = 0
+ for ch in core.reversed() {
+ if ch == " " || ch == "\t" { trailCount += 1 } else { break }
+ }
+ let trail = String(core.suffix(trailCount))
+ core = core.dropLast(trailCount)
+ let prevStyle = styleOf(i > 0 ? filtered[i - 1] : nil)
+ let nextStyle = styleOf(i + 1 < filtered.count ? filtered[i + 1] : nil)
+ if !lead.isEmpty { expelled.append(.run(RichRun(lead, style: intersection(run.style, prevStyle)))) }
+ if !core.isEmpty { expelled.append(.run(RichRun(String(core), style: run.style))) }
+ if !trail.isEmpty { expelled.append(.run(RichRun(trail, style: intersection(run.style, nextStyle)))) }
+ }
+
+ // Pass 3 — emit with an active-mark stack. Marks already open stay open
+ // when the new node still wants them (longest keepable prefix of the
+ // stack); the rest close top-down; new marks open in rank order.
+ var out = ""
+ var active: [Mark] = []
+ func closeDownTo(_ desired: [Mark]) {
+ var keep = 0
+ while keep < active.count, desired.contains(active[keep]) { keep += 1 }
+ while active.count > keep {
+ out += active.removeLast().delimiter
+ }
+ }
+ func open(_ desired: [Mark]) {
+ for mark in desired where !active.contains(mark) {
+ out += mark.delimiter
+ active.append(mark)
+ }
+ }
+ for node in expelled {
+ switch node {
+ case .run(let run) where run.style.code:
+ closeDownTo([])
+ // Delimiters sized past the content's longest backtick run, with
+ // one space of padding whenever the content holds any backtick.
+ var longest = 0
+ var current = 0
+ for ch in run.text {
+ current = ch == "`" ? current + 1 : 0
+ longest = max(longest, current)
+ }
+ let delim = String(repeating: "`", count: longest + 1)
+ let pad = longest > 0 ? " " : ""
+ out += delim + pad + run.text + pad + delim
+ case .run(let run):
+ let desired = marks(of: run.style)
+ closeDownTo(desired)
+ open(desired)
+ out += escaped(run.text, style: run.style)
+ case .brk(let style):
+ closeDownTo(marks(of: style))
+ out += "\n"
+ }
+ }
+ closeDownTo([])
+ return out
+}
+
+// MARK: - Blocks
+
+public func serializeComposer(_ blocks: [ComposerBlock]) -> String {
+ var parts: [String] = []
+ for block in blocks {
+ switch block {
+ case .paragraph(let nodes):
+ parts.append(renderInlines(nodes))
+ case .quote(let nodes):
+ // "> " before every emitted line — including empty ones, which
+ // keep the trailing space ("> a\n> \n> b"), matching the web.
+ let body = renderInlines(nodes)
+ parts.append(body.split(separator: "\n", omittingEmptySubsequences: false).map { "> " + $0 }.joined(separator: "\n"))
+ case .bullet(let items):
+ parts.append(items.map { "- " + renderInlines($0.map { .run($0) }) }.joined(separator: "\n"))
+ case .ordered(let start, let items):
+ let width = String(start + items.count - 1).count
+ parts.append(items.enumerated().map { i, runs in
+ let num = String(start + i)
+ return String(repeating: " ", count: width - num.count) + num + ". " + renderInlines(runs.map { .run($0) })
+ }.joined(separator: "\n"))
+ case .fence(let lang, let text):
+ // Opening run sized past any ≥3-backtick run inside; tildes and
+ // shorter runs never grow it. Empty fences collapse to two lines.
+ var longest = 0
+ var current = 0
+ for ch in text {
+ current = ch == "`" ? current + 1 : 0
+ if current >= 3 { longest = max(longest, current) }
+ }
+ let delim = String(repeating: "`", count: max(3, longest == 0 ? 0 : longest + 1))
+ parts.append(text.isEmpty ? delim + lang + "\n" + delim : delim + lang + "\n" + text + "\n" + delim)
+ }
+ }
+ return parts.joined(separator: "\n\n")
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift
new file mode 100644
index 0000000..fbd52dc
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift
@@ -0,0 +1,88 @@
+import XCTest
+@testable import LChatCore
+
+// The format-as-you-type brain (ComposerRules.swift) — semantics pinned here,
+// and the regex literals drift-gated against web/static/composer-pm.js (the
+// ComposerTypeaheadTests pattern): a web-side rule change fails this suite
+// until the native mirror follows.
+final class ComposerRulesTests: XCTestCase {
+ private var repoRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent() // Tests/LChatCoreTests
+ .deletingLastPathComponent() // Tests
+ .deletingLastPathComponent() // LChatCore
+ .deletingLastPathComponent() // ios
+ .deletingLastPathComponent() // repo
+ }
+
+ // MARK: inline completions
+
+ func testMarkCompletions() {
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "**bold**"),
+ InlineCompletion(replaceRange: 0..<8, content: "bold", mark: .bold))
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "say *it*"),
+ InlineCompletion(replaceRange: 4..<8, content: "it", mark: .italic))
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "`code`"),
+ InlineCompletion(replaceRange: 0..<6, content: "code", mark: .code))
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "~gone~"),
+ InlineCompletion(replaceRange: 0..<6, content: "gone", mark: .strike))
+ }
+
+ func testStrongWinsBeforeEm() {
+ // "**bold**" must fire strong, and the half-typed "**bold*" must not
+ // half-fire the em rule (its lookbehind rejects the second star).
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "**bold**")?.mark, .bold)
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "**bold*"))
+ }
+
+ func testTildeLookbehind() {
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "~~s~"), "double-tilde opener never pairs with a single closer")
+ }
+
+ func testNoFireOnWhitespaceEdges() {
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "** x**"))
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "*x *"))
+ }
+
+ func testUnicodeContentAndOffsets() {
+ // replaceRange is UTF-16: ✨ is BMP (1 unit), 😀 is a surrogate pair (2).
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "✨ **bí**"),
+ InlineCompletion(replaceRange: 2..<8, content: "bí", mark: .bold))
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "😀 **x**"),
+ InlineCompletion(replaceRange: 3..<8, content: "x", mark: .bold))
+ }
+
+ // MARK: block triggers
+
+ func testBlockTriggers() {
+ XCTAssertEqual(blockTrigger(lineUpToCaret: "- "), .bullet)
+ XCTAssertEqual(blockTrigger(lineUpToCaret: "* "), .bullet)
+ XCTAssertEqual(blockTrigger(lineUpToCaret: "3. "), .ordered(start: 3))
+ XCTAssertEqual(blockTrigger(lineUpToCaret: "> "), .quote)
+ XCTAssertEqual(blockTrigger(lineUpToCaret: "```"), .fence)
+ XCTAssertNil(blockTrigger(lineUpToCaret: "-x"))
+ XCTAssertNil(blockTrigger(lineUpToCaret: "1.5 "))
+ XCTAssertNil(blockTrigger(lineUpToCaret: "x - "))
+ }
+
+ // MARK: drift gate against the web rules
+
+ func testRegexLiteralsMatchComposerPmJs() throws {
+ let js = try String(contentsOf: repoRoot.appendingPathComponent("web/static/composer-pm.js"), encoding: .utf8)
+ // The exact JS regex literals the Swift patterns transliterate. If a
+ // rule changes on the web, its literal disappears and this fails.
+ let literals = [
+ #"/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/"#,
+ #"/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/"#,
+ #"/`([^`\s](?:[^`]*[^`\s])?)`$/"#,
+ #"/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/"#,
+ #"/^\s*([-*])\s$/"#,
+ #"/^(\d+)\.\s$/"#,
+ #"/^\s*>\s$/"#,
+ #"/^```$/"#,
+ ]
+ for literal in literals {
+ XCTAssertTrue(js.contains(literal), "composer-pm.js no longer contains \(literal) — mirror the rule change into ComposerRules.swift")
+ }
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerSerializeTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerSerializeTests.swift
new file mode 100644
index 0000000..caa9548
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerSerializeTests.swift
@@ -0,0 +1,218 @@
+import XCTest
+@testable import LChatCore
+
+// The composer serialization contract (issue #890): every vector in
+// Fixtures/composer-serialize-vector.json — a byte-identical copy of
+// test/event-vectors/composer-serialize.json, generated FROM the web
+// serializer (composer-md.js, the authority) and gated by
+// test/composer-vectors.test.mjs — must serialize to the same bytes here.
+// The reparse chain then feeds each vector's md through markdownTree,
+// linking this suite into the already-gated renderer twin.
+final class ComposerSerializeTests: XCTestCase {
+ struct VectorFile: Decodable { let vectors: [Vector] }
+ struct Vector: Decodable {
+ let name: String
+ let tree: [MDBlock]
+ let md: String
+ let reparse: [MDBlock]?
+ let note: String?
+ }
+
+ private func loadVectors() throws -> [Vector] {
+ let url = try XCTUnwrap(
+ Bundle.module.url(forResource: "Fixtures/composer-serialize-vector", withExtension: "json"),
+ "fixture missing — run cp test/event-vectors/composer-serialize.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json")
+ return try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)).vectors
+ }
+
+ // MDBlock (the vector tree vocabulary) → the composer model. Inline
+ // nesting flattens to style flags; a br inside a styled wrapper carries
+ // the wrapper's style (the marked-break case).
+ private func composerModel(_ tree: [MDBlock]) throws -> [ComposerBlock] {
+ try tree.map { block in
+ switch block {
+ case .p(let children):
+ return .paragraph(try inlineNodes(children))
+ case .quote(let children):
+ guard children.count == 1, case .p(let inner) = children[0] else {
+ throw XCTSkip("quote shapes beyond one paragraph are out of composer scope")
+ }
+ return .quote(try inlineNodes(inner))
+ case .ul(let items):
+ return .bullet(items: try items.map { try itemRuns($0) })
+ case .ol(let start, let items):
+ return .ordered(start: start, items: try items.map { try itemRuns($0) })
+ case .codeBlock(let text, let lang):
+ return .fence(lang: lang, text: text)
+ }
+ }
+ }
+
+ private func itemRuns(_ blocks: [MDBlock]) throws -> [RichRun] {
+ guard blocks.count == 1, case .p(let children) = blocks[0] else {
+ throw XCTSkip("multi-block list items are out of composer scope")
+ }
+ return try inlineNodes(children).map { node in
+ guard case .run(let run) = node else { throw Err.breakInListItem }
+ return run
+ }
+ }
+
+ private enum Err: Error { case breakInListItem, unsupportedInline }
+
+ private func inlineNodes(_ children: [MDInline], style: InlineStyle = .plain) throws -> [InlineNode] {
+ var out: [InlineNode] = []
+ for child in children {
+ switch child {
+ case .text(let s):
+ out.append(.run(RichRun(s, style: style)))
+ case .br:
+ out.append(.brk(style))
+ case .code(let s):
+ var codeStyle = style
+ codeStyle.code = true
+ out.append(.run(RichRun(s, style: codeStyle)))
+ case .strong(let inner):
+ var s = style
+ s.bold = true
+ out.append(contentsOf: try inlineNodes(inner, style: s))
+ case .em(let inner):
+ var s = style
+ s.italic = true
+ out.append(contentsOf: try inlineNodes(inner, style: s))
+ case .strike(let inner):
+ var s = style
+ s.strike = true
+ out.append(contentsOf: try inlineNodes(inner, style: s))
+ case .link:
+ throw Err.unsupportedInline // links are out of composer typing scope
+ }
+ }
+ return out
+ }
+
+ func testVectorCoverage() throws {
+ XCTAssertGreaterThanOrEqual(try loadVectors().count, 40)
+ }
+
+ func testAllVectorsSerialize() throws {
+ for v in try loadVectors() {
+ let model = try composerModel(v.tree)
+ XCTAssertEqual(
+ serializeComposer(model), v.md,
+ "vector '\(v.name)' diverged\(v.note.map { " (\($0))" } ?? "")")
+ }
+ }
+
+ // The semantic loop: what this serializer emits must parse back (on the
+ // already-vector-gated renderer twin) to the documented tree.
+ func testAllVectorsReparse() throws {
+ for v in try loadVectors() {
+ XCTAssertEqual(markdownTree(v.md), v.reparse ?? v.tree, "reparse of '\(v.name)' diverged")
+ }
+ }
+
+ // Edit round-trip identity, by construction: literal edit-load through
+ // plainModel emits every byte verbatim. Fuzz + fixed corpus.
+ func testPlainModelRoundTripsVerbatim() {
+ var corpus = [
+ "hello", "a\nb", "a\n\nb", "", " leading and trailing ",
+ "2*3 and ~x~ and `tick` and **markers**",
+ "- looks like a list\n1. and numbers\n> and quotes\n```\nand fences\n```",
+ "líne ✨ emoji 👨‍👩‍👧‍👦 and \t tabs",
+ "\\*already escaped\\* and \\ lone backslash",
+ ]
+ var seed: UInt64 = 0x11C4A7
+ func rand() -> UInt64 {
+ seed ^= seed << 13
+ seed ^= seed >> 7
+ seed ^= seed << 17
+ return seed
+ }
+ let alphabet = Array("ab *~`_->#\n\\1.✨é")
+ for _ in 0..<200 {
+ let len = Int(rand() % 40)
+ corpus.append(String((0..<len).map { _ in alphabet[Int(rand() % UInt64(alphabet.count))] }))
+ }
+ for s in corpus {
+ XCTAssertEqual(serializeComposer(plainModel(s)), s, "plain round-trip broke for \(s.debugDescription)")
+ }
+ }
+
+ // displayText is the storage-string twin (the typeahead space): plain
+ // content round-trips, markers/prefixes never appear.
+ func testDisplayTextMatchesPlainContent() throws {
+ // Display text is content-only: no marker or prefix bytes are ADDED
+ // by styling or block kinds (fence CONTENT may hold anything).
+ let styled: [ComposerBlock] = [
+ .paragraph([.run(RichRun("b", style: InlineStyle(bold: true))), .brk(.plain), .run(RichRun("i", style: InlineStyle(italic: true)))]),
+ .bullet(items: [[RichRun("a")]]),
+ .quote([.run(RichRun("q", style: InlineStyle(strike: true)))]),
+ ]
+ XCTAssertEqual(displayText(styled), "b\ni\na\nq")
+ XCTAssertEqual(displayText(plainModel("a\nb")), "a\nb")
+ XCTAssertEqual(displayText([.bullet(items: [[RichRun("a")], [RichRun("b")]])]), "a\nb")
+ XCTAssertEqual(displayText([.quote([.run(RichRun("q"))]), .fence(lang: "js", text: "x\ny")]), "q\nx\ny")
+ }
+
+ // The wedge regression pin: serializing a 100KB doc must be quick.
+ func testLargeDocSerializesFast() {
+ let big = String(repeating: "the quick brown fox jumps over the lazy dog \n", count: 2500) // ~112KB
+ let model = plainModel(big)
+ let start = Date()
+ _ = serializeComposer(model)
+ XCTAssertLessThan(Date().timeIntervalSince(start), 0.5, "100KB serialization should be far under 500ms")
+ }
+}
+
+// Grouping + line-model unit tests (the editor derives lines; grouping must
+// be deterministic).
+final class ComposerModelTests: XCTestCase {
+ func testParagraphLinesJoinWithBreaks() {
+ let lines = [
+ ComposerLine(kind: .paragraph, runs: [RichRun("a")], joinStyle: .plain),
+ ComposerLine(kind: .paragraph, runs: [RichRun("b")]),
+ ]
+ XCTAssertEqual(composerBlocks(lines), [.paragraph([.run(RichRun("a")), .brk(.plain), .run(RichRun("b"))])])
+ }
+
+ func testMarkedJoinStyleCarries() {
+ let bold = InlineStyle(bold: true)
+ let lines = [
+ ComposerLine(kind: .paragraph, runs: [RichRun("a", style: bold)], joinStyle: bold),
+ ComposerLine(kind: .paragraph, runs: [RichRun("b", style: bold)]),
+ ]
+ XCTAssertEqual(serializeComposer(composerBlocks(lines)), "**a\nb**")
+ }
+
+ func testKindTransitionsSplitBlocks() {
+ let lines = [
+ ComposerLine(kind: .paragraph, runs: [RichRun("intro")]),
+ ComposerLine(kind: .bullet, runs: [RichRun("one")]),
+ ComposerLine(kind: .bullet, runs: [RichRun("two")]),
+ ComposerLine(kind: .quote, runs: [RichRun("bye")]),
+ ]
+ XCTAssertEqual(serializeComposer(composerBlocks(lines)), "intro\n\n- one\n- two\n\n> bye")
+ }
+
+ func testOrderedStartFromFirstLine() {
+ let lines = [
+ ComposerLine(kind: .ordered(3), runs: [RichRun("a")]),
+ ComposerLine(kind: .ordered(9), runs: [RichRun("b")]), // later numbers ignored
+ ]
+ XCTAssertEqual(serializeComposer(composerBlocks(lines)), "3. a\n4. b")
+ }
+
+ func testFenceGroupsAndKeepsFirstLang() {
+ let lines = [
+ ComposerLine(kind: .fence(lang: "js"), runs: [RichRun("x = 1")]),
+ ComposerLine(kind: .fence(lang: ""), runs: [RichRun("y = 2")]),
+ ]
+ XCTAssertEqual(serializeComposer(composerBlocks(lines)), "```js\nx = 1\ny = 2\n```")
+ }
+
+ func testEmptyModelSerializesEmpty() {
+ XCTAssertEqual(serializeComposer(plainModel("")), "")
+ XCTAssertEqual(displayText(plainModel("")), "")
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json
new file mode 100644
index 0000000..c026352
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json
@@ -0,0 +1,1362 @@
+{
+ "description": "Composer serialization contract (issue #890): composer doc shape (the markdown.json tree vocabulary) -> exact wire bytes. The web serializer (composer-md.js serializeDoc) is the authority; the native twin (ComposerSerialize.swift) must emit identical bytes. `reparse` documents the accepted serialize-then-parse non-identities (expelled whitespace, code padding, typed double breaks becoming paragraphs); absent means parseMessage(md) returns the input tree. Scope notes: no nested blocks, no links from typing, no breaks inside list items — unreachable from composer typing on both platforms (web binds neither sinkListItem nor Tab; native has literal edit-load and plain paste). When a twin disagrees, fix the implementation, never the vector.",
+ "vectors": [
+ {
+ "name": "plain_single",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "md": "hello"
+ },
+ {
+ "name": "plain_multiline",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a\nb",
+ "note": "hard_break is a plain \\n on the wire"
+ },
+ {
+ "name": "double_break",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a\n\nb",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "note": "two typed breaks read back as two paragraphs"
+ },
+ {
+ "name": "bold",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**b**"
+ },
+ {
+ "name": "italic",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*i*"
+ },
+ {
+ "name": "strike_single_tilde",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "s"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "~s~",
+ "note": "single tilde — old clients render it perfectly"
+ },
+ {
+ "name": "inline_code",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "c"
+ }
+ ]
+ }
+ ],
+ "md": "`c`"
+ },
+ {
+ "name": "bold_italic",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bi"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "***bi***",
+ "note": "mark rank: em outermost"
+ },
+ {
+ "name": "strong_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**~x~**"
+ },
+ {
+ "name": "em_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*~x~*"
+ },
+ {
+ "name": "em_strong_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "***~x~***"
+ },
+ {
+ "name": "mixable_continuation",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bold "
+ }
+ ]
+ },
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "inner"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " bold"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**bold *inner* bold**",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bold "
+ },
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "inner"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " bold"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "note": "the already-open strong stays outside"
+ },
+ {
+ "name": "escape_star_in_strong",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a*b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a\\*b**",
+ "note": "in-mark escapes only"
+ },
+ {
+ "name": "escape_star_in_em",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*2\\*3*"
+ },
+ {
+ "name": "escape_tilde_in_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "a~b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "~a\\~b~"
+ },
+ {
+ "name": "zero_escape_plain",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3 and ~x~ stay"
+ }
+ ]
+ }
+ ],
+ "md": "2*3 and ~x~ stay",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3 and "
+ },
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " stay"
+ }
+ ]
+ }
+ ],
+ "note": "plain text serializes verbatim, never a backslash"
+ },
+ {
+ "name": "code_tick_sizing",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "a`b"
+ }
+ ]
+ }
+ ],
+ "md": "`` a`b ``",
+ "note": "delimiters sized past inner backticks, space-padded"
+ },
+ {
+ "name": "code_double_tick",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "a``b"
+ }
+ ]
+ }
+ ],
+ "md": "``` a``b ```"
+ },
+ {
+ "name": "code_space_padding",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": " x "
+ }
+ ]
+ }
+ ],
+ "md": "` x `",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "code_beside_marks",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " "
+ },
+ {
+ "t": "code",
+ "text": "c"
+ }
+ ]
+ }
+ ],
+ "md": "**b** `c`"
+ },
+ {
+ "name": "expel_trailing_ws",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a "
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "**a** b",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " b"
+ }
+ ]
+ }
+ ],
+ "note": "expelEnclosingWhitespace"
+ },
+ {
+ "name": "expel_leading_ws",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "a **b**",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a "
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "ws_only_styled",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a b",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a b"
+ }
+ ]
+ }
+ ],
+ "note": "whitespace-only styled run collapses to bare whitespace"
+ },
+ {
+ "name": "marked_break_spans",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a\nb**",
+ "note": "select-across-lines ⌘B: the break carries the mark"
+ },
+ {
+ "name": "unmarked_break_closes",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a**\n**b**",
+ "note": "typed Shift+Enter: plain break closes and reopens the mark"
+ },
+ {
+ "name": "break_mark_not_continuing",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "**a**\nb",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "note": "a break's mark drops when the mark doesn't continue"
+ },
+ {
+ "name": "unicode_styled",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "líne ✨ emoji"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**líne ✨ emoji**"
+ },
+ {
+ "name": "bullet_list",
+ "tree": [
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "- a\n- b"
+ },
+ {
+ "name": "bullet_styled_item",
+ "tree": [
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " x"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "- **b** x"
+ },
+ {
+ "name": "ordered_basic",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 1,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "1. a\n2. b"
+ },
+ {
+ "name": "ordered_start3",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 3,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "3. a\n4. b"
+ },
+ {
+ "name": "ordered_width_start9",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 9,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": " 9. a\n10. b",
+ "note": "width from digits(start+count-1): ' 9. a' aligns under '10. b'"
+ },
+ {
+ "name": "ordered_ten_items",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 1,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item1"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item2"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item3"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item4"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item5"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item6"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item7"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item8"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item9"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item10"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": " 1. item1\n 2. item2\n 3. item3\n 4. item4\n 5. item5\n 6. item6\n 7. item7\n 8. item8\n 9. item9\n10. item10"
+ },
+ {
+ "name": "quote_single",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "q"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> q"
+ },
+ {
+ "name": "quote_styled",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " i"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> **b** i"
+ },
+ {
+ "name": "quote_multiline",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> a\n> b"
+ },
+ {
+ "name": "quote_double_break",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> a\n> \n> b",
+ "reparse": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "note": "the blank quoted line carries a trailing space"
+ },
+ {
+ "name": "fence_plain",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "code",
+ "lang": ""
+ }
+ ],
+ "md": "```\ncode\n```"
+ },
+ {
+ "name": "fence_lang",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "code **here**",
+ "lang": "js"
+ }
+ ],
+ "md": "```js\ncode **here**\n```"
+ },
+ {
+ "name": "fence_inner_backticks",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "x = ```\ny",
+ "lang": ""
+ }
+ ],
+ "md": "````\nx = ```\ny\n````"
+ },
+ {
+ "name": "fence_empty",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "",
+ "lang": ""
+ }
+ ],
+ "md": "```\n```"
+ },
+ {
+ "name": "fence_tildes_stay",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "~~~",
+ "lang": ""
+ }
+ ],
+ "md": "```\n~~~\n```",
+ "note": "tildes never grow a backtick fence"
+ },
+ {
+ "name": "mixed_blocks",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "intro"
+ }
+ ]
+ },
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "one"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ ]
+ },
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "bye"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "intro\n\n- one\n- two\n\n> bye"
+ },
+ {
+ "name": "para_then_list",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "a\nb\n\n- x"
+ }
+ ]
+}
diff --git a/package.json b/package.json
index e6d60c7..25cd400 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/composer-vectors.test.mjs b/test/composer-vectors.test.mjs
new file mode 100644
index 0000000..d3519dc
--- /dev/null
+++ b/test/composer-vectors.test.mjs
@@ -0,0 +1,87 @@
+// 🤖 The composer serialization contract (issue #890): the vectors in
+// test/event-vectors/composer-serialize.json pin the wire bytes the WEB
+// serializer (composer-md.js, the authority) emits for each composer doc
+// shape, so the native twin (ComposerSerialize.swift, run by
+// ComposerSerializeTests over the byte-identical fixture) can never drift.
+// node test/composer-vectors.test.mjs
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { composerSchema as schema, serializeDoc } from "../web/static/composer-md.js";
+import { parseMessage } from "../web/static/markdown.js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+// The tree vocabulary (test/event-vectors/markdown.json's) → a ProseMirror
+// doc over the composer schema. Inline nesting becomes mark arrays; a br
+// inside a styled wrapper carries the wrapper's marks (the marked-break case).
+function inlineNodes(children, marks = []) {
+ const out = [];
+ for (const n of children) {
+ if (n.t === "text") out.push(schema.text(n.text, marks));
+ else if (n.t === "br") out.push(schema.nodes.hard_break.create(null, null, marks));
+ else if (n.t === "code") out.push(schema.text(n.text, [...marks, schema.marks.code.create()]));
+ else if (n.t === "strong" || n.t === "em") out.push(...inlineNodes(n.children, [...marks, schema.marks[n.t].create()]));
+ else if (n.t === "strike") out.push(...inlineNodes(n.children, [...marks, schema.marks.strike.create()]));
+ else if (n.t === "link") out.push(...inlineNodes(n.children, [...marks, schema.marks.link.create({ href: n.href })]));
+ else throw new Error("unknown inline " + n.t);
+ }
+ return out;
+}
+function blockNode(b) {
+ if (b.t === "p") return schema.nodes.paragraph.create(null, inlineNodes(b.children));
+ if (b.t === "quote") return schema.nodes.blockquote.create(null, b.children.map(blockNode));
+ if (b.t === "ul") return schema.nodes.bullet_list.create(null, b.items.map((item) => schema.nodes.list_item.create(null, item.map(blockNode))));
+ if (b.t === "ol") return schema.nodes.ordered_list.create({ order: b.start }, b.items.map((item) => schema.nodes.list_item.create(null, item.map(blockNode))));
+ if (b.t === "codeblock") return schema.nodes.code_block.create({ params: b.lang }, b.text ? [schema.text(b.text)] : []);
+ throw new Error("unknown block " + b.t);
+}
+export function docFromTree(tree) {
+ return schema.nodes.doc.create(null, tree.map(blockNode));
+}
+
+const canonical = readFileSync(join(__dirname, "event-vectors", "composer-serialize.json"), "utf8");
+const { vectors } = JSON.parse(canonical);
+
+await test("vector file has substantial coverage", () => {
+ assert.ok(vectors.length >= 40, `only ${vectors.length} vectors`);
+});
+
+for (const v of vectors) {
+ await test(`serialize: ${v.name}`, () => {
+ assert.equal(serializeDoc(docFromTree(v.tree)), v.md, v.note || "");
+ });
+ await test(`reparse: ${v.name}`, () => {
+ assert.deepEqual(parseMessage(v.md), v.reparse ?? v.tree);
+ });
+}
+
+await test("ios fixture copy is byte-identical to canonical", () => {
+ const bundled = readFileSync(
+ join(__dirname, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures", "composer-serialize-vector.json"),
+ "utf8"
+ );
+ assert.strictEqual(
+ bundled,
+ canonical,
+ "run cp test/event-vectors/composer-serialize.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json"
+ );
+});
+
+if (failures) {
+ console.error(`\ncomposer-vectors: ${failures} test(s) failed`);
+ process.exit(1);
+}
+console.log("\ncomposer-vectors: all tests passed");
diff --git a/test/event-vectors/composer-serialize.json b/test/event-vectors/composer-serialize.json
new file mode 100644
index 0000000..c026352
--- /dev/null
+++ b/test/event-vectors/composer-serialize.json
@@ -0,0 +1,1362 @@
+{
+ "description": "Composer serialization contract (issue #890): composer doc shape (the markdown.json tree vocabulary) -> exact wire bytes. The web serializer (composer-md.js serializeDoc) is the authority; the native twin (ComposerSerialize.swift) must emit identical bytes. `reparse` documents the accepted serialize-then-parse non-identities (expelled whitespace, code padding, typed double breaks becoming paragraphs); absent means parseMessage(md) returns the input tree. Scope notes: no nested blocks, no links from typing, no breaks inside list items — unreachable from composer typing on both platforms (web binds neither sinkListItem nor Tab; native has literal edit-load and plain paste). When a twin disagrees, fix the implementation, never the vector.",
+ "vectors": [
+ {
+ "name": "plain_single",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "md": "hello"
+ },
+ {
+ "name": "plain_multiline",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a\nb",
+ "note": "hard_break is a plain \\n on the wire"
+ },
+ {
+ "name": "double_break",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a\n\nb",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "note": "two typed breaks read back as two paragraphs"
+ },
+ {
+ "name": "bold",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**b**"
+ },
+ {
+ "name": "italic",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*i*"
+ },
+ {
+ "name": "strike_single_tilde",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "s"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "~s~",
+ "note": "single tilde — old clients render it perfectly"
+ },
+ {
+ "name": "inline_code",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "c"
+ }
+ ]
+ }
+ ],
+ "md": "`c`"
+ },
+ {
+ "name": "bold_italic",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bi"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "***bi***",
+ "note": "mark rank: em outermost"
+ },
+ {
+ "name": "strong_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**~x~**"
+ },
+ {
+ "name": "em_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*~x~*"
+ },
+ {
+ "name": "em_strong_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "***~x~***"
+ },
+ {
+ "name": "mixable_continuation",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bold "
+ }
+ ]
+ },
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "inner"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " bold"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**bold *inner* bold**",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "bold "
+ },
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "inner"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " bold"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "note": "the already-open strong stays outside"
+ },
+ {
+ "name": "escape_star_in_strong",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a*b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a\\*b**",
+ "note": "in-mark escapes only"
+ },
+ {
+ "name": "escape_star_in_em",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "em",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "*2\\*3*"
+ },
+ {
+ "name": "escape_tilde_in_strike",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "a~b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "~a\\~b~"
+ },
+ {
+ "name": "zero_escape_plain",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3 and ~x~ stay"
+ }
+ ]
+ }
+ ],
+ "md": "2*3 and ~x~ stay",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "2*3 and "
+ },
+ {
+ "t": "strike",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " stay"
+ }
+ ]
+ }
+ ],
+ "note": "plain text serializes verbatim, never a backslash"
+ },
+ {
+ "name": "code_tick_sizing",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "a`b"
+ }
+ ]
+ }
+ ],
+ "md": "`` a`b ``",
+ "note": "delimiters sized past inner backticks, space-padded"
+ },
+ {
+ "name": "code_double_tick",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "a``b"
+ }
+ ]
+ }
+ ],
+ "md": "``` a``b ```"
+ },
+ {
+ "name": "code_space_padding",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": " x "
+ }
+ ]
+ }
+ ],
+ "md": "` x `",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "code",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "code_beside_marks",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " "
+ },
+ {
+ "t": "code",
+ "text": "c"
+ }
+ ]
+ }
+ ],
+ "md": "**b** `c`"
+ },
+ {
+ "name": "expel_trailing_ws",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a "
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "**a** b",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " b"
+ }
+ ]
+ }
+ ],
+ "note": "expelEnclosingWhitespace"
+ },
+ {
+ "name": "expel_leading_ws",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "a **b**",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a "
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "ws_only_styled",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "a b",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a b"
+ }
+ ]
+ }
+ ],
+ "note": "whitespace-only styled run collapses to bare whitespace"
+ },
+ {
+ "name": "marked_break_spans",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a\nb**",
+ "note": "select-across-lines ⌘B: the break carries the mark"
+ },
+ {
+ "name": "unmarked_break_closes",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**a**\n**b**",
+ "note": "typed Shift+Enter: plain break closes and reopens the mark"
+ },
+ {
+ "name": "break_mark_not_continuing",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "md": "**a**\nb",
+ "reparse": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ],
+ "note": "a break's mark drops when the mark doesn't continue"
+ },
+ {
+ "name": "unicode_styled",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "líne ✨ emoji"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "**líne ✨ emoji**"
+ },
+ {
+ "name": "bullet_list",
+ "tree": [
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "- a\n- b"
+ },
+ {
+ "name": "bullet_styled_item",
+ "tree": [
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " x"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "- **b** x"
+ },
+ {
+ "name": "ordered_basic",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 1,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "1. a\n2. b"
+ },
+ {
+ "name": "ordered_start3",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 3,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "3. a\n4. b"
+ },
+ {
+ "name": "ordered_width_start9",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 9,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": " 9. a\n10. b",
+ "note": "width from digits(start+count-1): ' 9. a' aligns under '10. b'"
+ },
+ {
+ "name": "ordered_ten_items",
+ "tree": [
+ {
+ "t": "ol",
+ "start": 1,
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item1"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item2"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item3"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item4"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item5"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item6"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item7"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item8"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item9"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "item10"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": " 1. item1\n 2. item2\n 3. item3\n 4. item4\n 5. item5\n 6. item6\n 7. item7\n 8. item8\n 9. item9\n10. item10"
+ },
+ {
+ "name": "quote_single",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "q"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> q"
+ },
+ {
+ "name": "quote_styled",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "strong",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "text",
+ "text": " i"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> **b** i"
+ },
+ {
+ "name": "quote_multiline",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> a\n> b"
+ },
+ {
+ "name": "quote_double_break",
+ "tree": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "> a\n> \n> b",
+ "reparse": [
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "note": "the blank quoted line carries a trailing space"
+ },
+ {
+ "name": "fence_plain",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "code",
+ "lang": ""
+ }
+ ],
+ "md": "```\ncode\n```"
+ },
+ {
+ "name": "fence_lang",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "code **here**",
+ "lang": "js"
+ }
+ ],
+ "md": "```js\ncode **here**\n```"
+ },
+ {
+ "name": "fence_inner_backticks",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "x = ```\ny",
+ "lang": ""
+ }
+ ],
+ "md": "````\nx = ```\ny\n````"
+ },
+ {
+ "name": "fence_empty",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "",
+ "lang": ""
+ }
+ ],
+ "md": "```\n```"
+ },
+ {
+ "name": "fence_tildes_stay",
+ "tree": [
+ {
+ "t": "codeblock",
+ "text": "~~~",
+ "lang": ""
+ }
+ ],
+ "md": "```\n~~~\n```",
+ "note": "tildes never grow a backtick fence"
+ },
+ {
+ "name": "mixed_blocks",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "intro"
+ }
+ ]
+ },
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "one"
+ }
+ ]
+ }
+ ],
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ ]
+ },
+ {
+ "t": "quote",
+ "children": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "bye"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "md": "intro\n\n- one\n- two\n\n> bye"
+ },
+ {
+ "name": "para_then_list",
+ "tree": [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "a"
+ },
+ {
+ "t": "br"
+ },
+ {
+ "t": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "t": "ul",
+ "items": [
+ [
+ {
+ "t": "p",
+ "children": [
+ {
+ "t": "text",
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ ]
+ }
+ ],
+ "md": "a\nb\n\n- x"
+ }
+ ]
+}