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>
9 files changed,
+3550
−1
ios/LChatCore/Sources/LChatCore/ComposerModel.swift+173 −0ios/LChatCore/Sources/LChatCore/ComposerRules.swift+67 −0ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift+192 −0ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift+88 −0ios/LChatCore/Tests/LChatCoreTests/ComposerSerializeTests.swift+218 −0ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json+1362 −0package.json+1 −1test/composer-vectors.test.mjs+87 −0test/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" |
| + } |
| + ] |
| +} |