eleven / ocho Public

ios: the native CommonMark twin — swift-markdown walked to the vector tree (#531) (#878)

MarkdownTree.swift parses with swift-markdown 0.8.0 (exact pin, first
LChatCore dependency) and normalizes cmark's Markup tree to the same
allowlisted semantic tree as web/static/markdown.js: .disableSmartOpts
(cmark curls quotes by default), literal fallbacks for the constructs
cmark can't disable (headings, tables, tasklists, hr, HTML, indented
code, images), house linkify via the existing tokenizeText, and
merge-then-linkify text runs so both twins produce one canonical tree.

MarkdownVectorTests runs all 66 shared vectors from the byte-gated
fixture. No UI change yet — MessageBubble swaps in the next slice.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 62e6407f6cfbf6868ca684f48c65f501424e4b25 parent b98f7b6
4 files changed, +521 −1
  • ios/LChatCore/Package.resolved +23 −0
  • ios/LChatCore/Package.swift +13 −1
  • ios/LChatCore/Sources/LChatCore/MarkdownTree.swift +400 −0
  • ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift +85 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
new file mode 100644
index 0000000..d82f91f
--- /dev/null
+++ b/ios/LChatCore/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-cmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-cmark.git",
+ "state" : {
+ "revision" : "924936d0427cb25a61169739a7660230bffa6ea6",
+ "version" : "0.8.0"
+ }
+ },
+ {
+ "identity" : "swift-markdown",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-markdown.git",
+ "state" : {
+ "revision" : "3c6f9523da3a1ec2fd829673e472d95b8097a3b8",
+ "version" : "0.8.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/ios/LChatCore/Package.swift b/ios/LChatCore/Package.swift
index 2c880ab..fc8ce12 100644
--- a/ios/LChatCore/Package.swift
+++ b/ios/LChatCore/Package.swift
@@ -11,13 +11,25 @@ let package = Package(
products: [
.library(name: "LChatCore", targets: ["LChatCore"])
],
+ dependencies: [
+ // The native half of the twin-parsed CommonMark subset (#531):
+ // cmark-gfm via swift-markdown, walked with the same allowlist as
+ // web/static/markdown.js and held to the same vectors
+ // (Fixtures/markdown-vector.json). Exact pin — like Sparkle in
+ // project.yml — so the twins only ever bump deliberately.
+ .package(url: "https://github.com/swiftlang/swift-markdown.git", exact: "0.8.0")
+ ],
targets: [
// Flows/ carries the conversation scripts (byte-derived copies of
// web/static/apps/*.flow.js, drift-gated by test/flow.test.mjs).
// Schemas/ carries the app-schema JSON (byte-identical copies of the
// canonical web/static/apps/schemas/, drift-gated by
// test/schema-engine.test.mjs).
- .target(name: "LChatCore", resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas"), .copy("Marks")]),
+ .target(
+ name: "LChatCore",
+ dependencies: [.product(name: "Markdown", package: "swift-markdown")],
+ resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas"), .copy("Marks")]
+ ),
// The cross-implementation drift gates (fixtures generated by the web
// crypto — see test/threadkey.test.mjs). `swift test` runs them natively
// on the Mac, no simulator needed.
diff --git a/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift
new file mode 100644
index 0000000..17a8c8c
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift
@@ -0,0 +1,400 @@
+import Foundation
+import Markdown
+
+// The native twin of web/static/markdown.js (issue #531): swift-markdown
+// (cmark-gfm) parses the message, and this walk normalizes the Markup tree to
+// the SAME allowlisted semantic tree the web produces — pinned, node for node,
+// by Fixtures/markdown-vector.json (MarkdownVectorTests). When the twins
+// disagree, fix the implementations, never the vectors.
+//
+// Allowlist: emphasis/strong, inline code, links (http(s) only), lists,
+// blockquotes, fenced code, strike (single or double tilde), breaks, escapes,
+// entities. Everything else renders as literal text. cmark can't *disable*
+// constructs the way markdown-it's zero preset can, so disallowed nodes are
+// converted back to literal text here — mostly via their source ranges, so the
+// member sees exactly what was typed.
+//
+// Two cmark-side traps this file guards (both vector-pinned):
+// - `.disableSmartOpts` is REQUIRED: cmark curls quotes/dashes by default.
+// - The strikethrough/tasklist/table extensions are always attached; their
+// nodes must fold back to the web-identical shape.
+
+// MARK: - The tree (the vector contract)
+
+public enum MDInline: Equatable {
+ case text(String)
+ case br
+ case code(String)
+ case link(href: String, children: [MDInline])
+ case strong([MDInline])
+ case em([MDInline])
+ case strike([MDInline])
+}
+
+public enum MDBlock: Equatable {
+ case p([MDInline])
+ case quote([MDBlock])
+ case ul(items: [[MDBlock]])
+ case ol(start: Int, items: [[MDBlock]])
+ case codeBlock(text: String, lang: String)
+}
+
+// MARK: - Parse + walk
+
+public func markdownTree(_ text: String) -> [MDBlock] {
+ let doc = Document(parsing: text, options: [.disableSmartOpts])
+ let walker = MDTreeWalker(source: text)
+ return walker.blocks(of: doc)
+}
+
+private struct MDTreeWalker {
+ let source: String
+ let lines: [Substring]
+
+ init(source: String) {
+ self.source = source
+ self.lines = source.split(separator: "\n", omittingEmptySubsequences: false)
+ }
+
+ // Whole source lines covered by a node — the same recovery web uses for
+ // unknown block tokens (token.map). 1-based, inclusive.
+ func sliceLines(_ range: SourceRange?) -> [String] {
+ guard let range = range else { return [] }
+ let lo = max(range.lowerBound.line, 1) - 1
+ let hi = min(range.upperBound.line, lines.count) - 1
+ guard lo <= hi else { return [] }
+ return lines[lo...hi].map(String.init)
+ }
+
+ // Column-accurate slice for inline fallbacks (cmark columns count UTF-8
+ // bytes; upperBound is exclusive in swift-markdown's Range).
+ func exactSlice(_ range: SourceRange?) -> String? {
+ guard let range = range else { return nil }
+ let loLine = range.lowerBound.line - 1
+ let hiLine = range.upperBound.line - 1
+ guard loLine >= 0, hiLine < lines.count, loLine <= hiLine else { return nil }
+ func clip(_ line: Substring, from: Int, to: Int) -> String? {
+ let bytes = Array(line.utf8)
+ let lo = max(from, 0), hi = min(to, bytes.count)
+ guard lo <= hi else { return nil }
+ return String(decoding: bytes[lo..<hi], as: UTF8.self)
+ }
+ if loLine == hiLine {
+ return clip(lines[loLine], from: range.lowerBound.column - 1, to: range.upperBound.column - 1)
+ }
+ var parts: [String] = []
+ guard let first = clip(lines[loLine], from: range.lowerBound.column - 1, to: Array(lines[loLine].utf8).count) else { return nil }
+ parts.append(first)
+ for i in (loLine + 1)..<hiLine { parts.append(String(lines[i])) }
+ guard let last = clip(lines[hiLine], from: 0, to: range.upperBound.column - 1) else { return nil }
+ parts.append(last)
+ return parts.joined(separator: "\n")
+ }
+
+ // MARK: blocks
+
+ func blocks(of markup: Markup) -> [MDBlock] {
+ var out: [MDBlock] = []
+ for child in markup.children {
+ out.append(contentsOf: block(child))
+ }
+ return out
+ }
+
+ private func block(_ node: Markup) -> [MDBlock] {
+ switch node {
+ case let p as Paragraph:
+ return [.p(inlines(of: p))]
+ case let q as BlockQuote:
+ return [.quote(blocks(of: q))]
+ case let list as UnorderedList:
+ return [.ul(items: list.children.map { listItem($0) })]
+ case let list as OrderedList:
+ return [.ol(start: Int(list.startIndex), items: list.children.map { listItem($0) })]
+ case let code as CodeBlock:
+ return [codeBlockOrIndented(code)]
+ case let h as Heading:
+ return [heading(h)]
+ case is ThematicBreak:
+ // "---" and friends: the exact marker line, literal.
+ let line = sliceLines(node.range).first ?? "---"
+ return [.p([.text(line)])]
+ case let html as HTMLBlock:
+ // Accepted divergence from web (which never forms HTML blocks and
+ // still inline-formats between tags): the raw lines, verbatim.
+ let raw = html.rawHTML.hasSuffix("\n") ? String(html.rawHTML.dropLast()) : html.rawHTML
+ var children: [MDInline] = []
+ for (i, line) in raw.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
+ if i > 0 { children.append(.br) }
+ children.append(.text(String(line)))
+ }
+ return [.p(children)]
+ case is Markdown.Table:
+ // A lone table row can't re-form a table, so re-parsing line by
+ // line matches web (pipes literal, inline marks still format).
+ return [lineByLine(sliceLines(node.range))]
+ default:
+ // Unreachable for parsed CommonMark; exact source as a safety net.
+ let slice = sliceLines(node.range)
+ return slice.isEmpty ? [] : [lineByLine(slice)]
+ }
+ }
+
+ private func listItem(_ node: Markup) -> [MDBlock] {
+ guard let item = node as? ListItem else { return blocks(of: node) }
+ var itemBlocks = blocks(of: item)
+ // cmark-gfm force-parses "- [x] " task items; web keeps the checkbox
+ // literal. Re-synthesize the prefix into the item's first paragraph.
+ if let box = item.checkbox {
+ let prefix = MDInline.text(box == .checked ? "[x] " : "[ ] ")
+ if case .p(let children) = itemBlocks.first {
+ itemBlocks[0] = .p(mergeText([prefix] + children))
+ } else {
+ itemBlocks.insert(.p([prefix]), at: 0)
+ }
+ }
+ return itemBlocks
+ }
+
+ private func codeBlockOrIndented(_ code: CodeBlock) -> MDBlock {
+ let body = code.code.hasSuffix("\n") ? String(code.code.dropLast()) : code.code
+ // Fenced vs indented: web never enables the 4-space rule (it mangles
+ // pasted text), so an indented block folds back to plain lines. cmark
+ // doesn't flag which form parsed — sniff the opening source line,
+ // stripping blockquote markers (each allows ≤3 leading spaces) so a
+ // fence inside a quote still reads as a fence. Whatever spaces remain
+ // after the final strip are the code indent: a 4th one means indented.
+ var opener = Substring(sliceLines(code.range).first ?? "")
+ stripQuotes: while true {
+ var spaces = 0
+ while spaces < 3, opener.first == " " { opener = opener.dropFirst(); spaces += 1 }
+ guard opener.first == ">" else { break stripQuotes }
+ opener = opener.dropFirst()
+ if opener.first == " " { opener = opener.dropFirst() }
+ }
+ if opener.hasPrefix("```") || opener.hasPrefix("~~~") {
+ return .codeBlock(text: body, lang: (code.language ?? "").trimmingCharacters(in: .whitespaces))
+ }
+ var children: [MDInline] = []
+ for (i, line) in body.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
+ if i > 0 { children.append(.br) }
+ children.append(.text(String(line)))
+ }
+ return .p(children)
+ }
+
+ private func heading(_ h: Heading) -> MDBlock {
+ let slice = sliceLines(h.range)
+ let atx = (slice.first ?? "").drop(while: { $0 == " " }).hasPrefix("#")
+ if atx {
+ // Web leaves "# hi **b**" a paragraph whose text keeps the marker
+ // while inline marks still format — reconstruct the same shape.
+ let marker = String(repeating: "#", count: h.level)
+ var children = inlines(of: h)
+ children.insert(.text(children.isEmpty ? marker : marker + " "), at: 0)
+ return .p(mergeText(children))
+ }
+ // Setext ("title\n==="): web keeps both lines as a paragraph.
+ return lineByLine(slice)
+ }
+
+ // Re-parse each source line alone (none can re-form its multi-line
+ // construct) and join with breaks — matching how web's disabled block
+ // rules leave the lines as one paragraph with inline formatting intact.
+ private func lineByLine(_ slice: [String]) -> MDBlock {
+ var children: [MDInline] = []
+ for (i, line) in slice.enumerated() {
+ if i > 0 { children.append(.br) }
+ let doc = Document(parsing: line, options: [.disableSmartOpts])
+ let kids = Array(doc.children)
+ if kids.count == 1, let p = kids.first as? Paragraph {
+ let walker = MDTreeWalker(source: line)
+ children.append(contentsOf: walker.inlines(of: p))
+ } else if !line.isEmpty {
+ children.append(.text(line))
+ }
+ }
+ return .p(mergeText(children))
+ }
+
+ // MARK: inlines
+
+ // Plain text accumulates across adjacent literal nodes and is linkified in
+ // one pass (format.js tokenizeText — the house bare-URL rules), matching
+ // web's merge-then-linkify order. Inside an explicit link, text stays
+ // plain: links never nest.
+ private struct InlineSink {
+ var out: [MDInline] = []
+ var pending = ""
+ let linkify: Bool
+
+ mutating func flush() {
+ guard !pending.isEmpty else { return }
+ if linkify {
+ for tok in tokenizeText(pending) {
+ if let href = tok.href {
+ out.append(.link(href: href.absoluteString, children: [.text(tok.text)]))
+ } else {
+ appendText(tok.text)
+ }
+ }
+ } else {
+ appendText(pending)
+ }
+ pending = ""
+ }
+
+ mutating func appendText(_ s: String) {
+ guard !s.isEmpty else { return }
+ if case .text(let prev) = out.last {
+ out[out.count - 1] = .text(prev + s)
+ } else {
+ out.append(.text(s))
+ }
+ }
+
+ mutating func push(_ node: MDInline) {
+ flush()
+ out.append(node)
+ }
+ }
+
+ func inlines(of markup: Markup, linkify: Bool = true) -> [MDInline] {
+ var sink = InlineSink(linkify: linkify)
+ for child in markup.children {
+ inline(child, into: &sink)
+ }
+ sink.flush()
+ return sink.out
+ }
+
+ private func inline(_ node: Markup, into sink: inout InlineSink) {
+ switch node {
+ case let t as Markdown.Text:
+ sink.pending += t.string
+ case is SoftBreak, is LineBreak:
+ sink.push(.br)
+ case let c as InlineCode:
+ sink.push(.code(c.code))
+ case let e as Emphasis:
+ sink.push(.em(inlines(of: e, linkify: sink.linkify)))
+ case let s as Strong:
+ sink.push(.strong(inlines(of: s, linkify: sink.linkify)))
+ case let s as Strikethrough:
+ sink.push(.strike(inlines(of: s, linkify: sink.linkify)))
+ case let l as Markdown.Link:
+ let dest = l.destination ?? ""
+ if dest.lowercased().hasPrefix("http://") || dest.lowercased().hasPrefix("https://") {
+ sink.push(.link(href: dest, children: inlines(of: l, linkify: false)))
+ } else {
+ // Web's validateLink rejected it before it ever tokenized, so
+ // the source text stays literal (and linkifies as plain text).
+ sink.pending += exactSlice(l.range) ?? "[\(plainText(of: l))](\(dest))"
+ }
+ case let img as Markdown.Image:
+ // Images are outside the allowlist on both twins. A valid-scheme
+ // image was a real token on web (reconstructed, never linkified);
+ // an invalid one never parsed there, so it stays linkifiable text.
+ let src = img.source ?? ""
+ let literal = exactSlice(img.range) ?? "![\(plainText(of: img))](\(src))"
+ if src.lowercased().hasPrefix("http://") || src.lowercased().hasPrefix("https://") {
+ sink.flush()
+ sink.appendText(literal)
+ } else {
+ sink.pending += literal
+ }
+ case let html as InlineHTML:
+ // Web never parses inline HTML — the tag is plain text in the same
+ // run as its neighbours (and linkifies with them).
+ sink.pending += html.rawHTML
+ default:
+ sink.pending += exactSlice(node.range) ?? plainText(of: node)
+ }
+ }
+
+ private func plainText(of markup: Markup) -> String {
+ var out = ""
+ for child in markup.children {
+ if let t = child as? Markdown.Text { out += t.string } else { out += plainText(of: child) }
+ }
+ return out
+ }
+}
+
+private func mergeText(_ children: [MDInline]) -> [MDInline] {
+ var out: [MDInline] = []
+ for node in children {
+ if case .text(let s) = node, case .text(let prev)? = out.last {
+ out[out.count - 1] = .text(prev + s)
+ } else {
+ out.append(node)
+ }
+ }
+ return out
+}
+
+// MARK: - Codable (mirrors the vector JSON exactly)
+
+extension MDInline: Codable {
+ private enum CodingKeys: String, CodingKey { case t, text, href, children }
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ switch try c.decode(String.self, forKey: .t) {
+ case "text": self = .text(try c.decode(String.self, forKey: .text))
+ case "br": self = .br
+ case "code": self = .code(try c.decode(String.self, forKey: .text))
+ case "link": self = .link(href: try c.decode(String.self, forKey: .href),
+ children: try c.decode([MDInline].self, forKey: .children))
+ case "strong": self = .strong(try c.decode([MDInline].self, forKey: .children))
+ case "em": self = .em(try c.decode([MDInline].self, forKey: .children))
+ case "strike": self = .strike(try c.decode([MDInline].self, forKey: .children))
+ case let t: throw DecodingError.dataCorruptedError(forKey: .t, in: c, debugDescription: "unknown inline \(t)")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .text(let s): try c.encode("text", forKey: .t); try c.encode(s, forKey: .text)
+ case .br: try c.encode("br", forKey: .t)
+ case .code(let s): try c.encode("code", forKey: .t); try c.encode(s, forKey: .text)
+ case .link(let href, let children):
+ try c.encode("link", forKey: .t); try c.encode(href, forKey: .href); try c.encode(children, forKey: .children)
+ case .strong(let children): try c.encode("strong", forKey: .t); try c.encode(children, forKey: .children)
+ case .em(let children): try c.encode("em", forKey: .t); try c.encode(children, forKey: .children)
+ case .strike(let children): try c.encode("strike", forKey: .t); try c.encode(children, forKey: .children)
+ }
+ }
+}
+
+extension MDBlock: Codable {
+ private enum CodingKeys: String, CodingKey { case t, children, items, start, text, lang }
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ switch try c.decode(String.self, forKey: .t) {
+ case "p": self = .p(try c.decode([MDInline].self, forKey: .children))
+ case "quote": self = .quote(try c.decode([MDBlock].self, forKey: .children))
+ case "ul": self = .ul(items: try c.decode([[MDBlock]].self, forKey: .items))
+ case "ol": self = .ol(start: try c.decode(Int.self, forKey: .start),
+ items: try c.decode([[MDBlock]].self, forKey: .items))
+ case "codeblock": self = .codeBlock(text: try c.decode(String.self, forKey: .text),
+ lang: try c.decode(String.self, forKey: .lang))
+ case let t: throw DecodingError.dataCorruptedError(forKey: .t, in: c, debugDescription: "unknown block \(t)")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .p(let children): try c.encode("p", forKey: .t); try c.encode(children, forKey: .children)
+ case .quote(let children): try c.encode("quote", forKey: .t); try c.encode(children, forKey: .children)
+ case .ul(let items): try c.encode("ul", forKey: .t); try c.encode(items, forKey: .items)
+ case .ol(let start, let items):
+ try c.encode("ol", forKey: .t); try c.encode(start, forKey: .start); try c.encode(items, forKey: .items)
+ case .codeBlock(let text, let lang):
+ try c.encode("codeblock", forKey: .t); try c.encode(text, forKey: .text); try c.encode(lang, forKey: .lang)
+ }
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift
new file mode 100644
index 0000000..6f6cad5
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift
@@ -0,0 +1,85 @@
+import XCTest
+@testable import LChatCore
+
+// The markdown rendering contract (issue #531): every vector in
+// Fixtures/markdown-vector.json — a byte-identical copy of the canonical
+// test/event-vectors/markdown.json, gated by test/markdown.test.mjs — must
+// normalize to the same semantic tree on both twins. The web side asserts
+// parseMessage(input) == tree; this suite asserts markdownTree(input) == tree.
+// When a vector fails here, the twins have diverged: fix MarkdownTree.swift
+// (or markdown.js), never the vector.
+final class MarkdownVectorTests: XCTestCase {
+ struct VectorFile: Decodable {
+ let vectors: [Vector]
+ }
+ struct Vector: Decodable {
+ let name: String
+ let input: String
+ let tree: [MDBlock]
+ let note: String?
+ }
+
+ private func loadVectors() throws -> [Vector] {
+ let url = try XCTUnwrap(
+ Bundle.module.url(forResource: "Fixtures/markdown-vector", withExtension: "json"),
+ "Fixtures/markdown-vector.json missing — run cp test/event-vectors/markdown.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json")
+ return try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)).vectors
+ }
+
+ private func json(_ blocks: [MDBlock]) -> String {
+ let enc = JSONEncoder()
+ enc.outputFormatting = [.sortedKeys]
+ return (try? String(decoding: enc.encode(blocks), as: UTF8.self)) ?? "<encode failed>"
+ }
+
+ func testVectorCoverage() throws {
+ let vectors = try loadVectors()
+ XCTAssertGreaterThanOrEqual(vectors.count, 60, "vector corpus shrank — regenerate from web")
+ }
+
+ func testAllVectors() throws {
+ for v in try loadVectors() {
+ let got = markdownTree(v.input)
+ XCTAssertEqual(
+ got, v.tree,
+ """
+ vector '\(v.name)' diverged\(v.note.map { " (\($0))" } ?? "")
+ input: \(v.input.debugDescription)
+ expected: \(json(v.tree))
+ got: \(json(got))
+ """)
+ }
+ }
+
+ // Beyond the shared corpus: edges the web suite pins in test/markdown.test.mjs.
+ func testHostileSchemesNeverLink() {
+ let tree = markdownTree("[a](data:text/html,x) [b](vbscript:x) <ftp://x.com>")
+ func assertNoLinks(_ inlines: [MDInline]) {
+ for node in inlines {
+ switch node {
+ case .link(let href, _):
+ XCTAssertTrue(href.lowercased().hasPrefix("http"), "non-http(s) link leaked: \(href)")
+ case .strong(let c), .em(let c), .strike(let c):
+ assertNoLinks(c)
+ default: break
+ }
+ }
+ }
+ for block in tree {
+ if case .p(let children) = block { assertNoLinks(children) }
+ }
+ }
+
+ func testMismatchedTildesStayLiteral() {
+ XCTAssertEqual(markdownTree("~a~~"), [.p([.text("~a~~")])])
+ }
+
+ func testAdjacentTextMerges() {
+ XCTAssertEqual(markdownTree("a \\* b &amp; c"), [.p([.text("a * b & c")])])
+ }
+
+ func testEmptyInput() {
+ XCTAssertEqual(markdownTree(""), [])
+ XCTAssertEqual(markdownTree(" "), [])
+ }
+}