eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// The wire serializer for the native composer model (issue #890) — a
4// byte-for-byte twin of the web's composer-md.js serializeDoc (a specialized
5// port of prosemirror-markdown's renderInline state machine). Every rule here
6// is pinned by Fixtures/composer-serialize-vector.json, generated FROM the
7// web serializer: when the twins disagree, fix this file, never the vectors.
8//
9// The rules that matter:
10// - Zero-escape plain text (never a backslash — old clients render them
11// literally); escapes only inside marked runs (* in em/strong, ~ in strike).
12// - Mark rank em → strong → strike, outermost-first (***~x~***); marks
13// already open stay open across style changes (mixable continuation:
14// **bold *inner* bold**).
15// - A break's marks survive only if the mark continues onto non-whitespace
16// text (**a\nb** from a styled break vs **a**\n**b** from a typed one).
17// - expelEnclosingWhitespace: whitespace at a mark boundary moves outside
18// the markers; a whitespace-only styled run collapses to bare whitespace.
19// - Code spans size delimiters past inner backticks and space-pad only when
20// the content contains a backtick. Strike is single ~. Lists are tight.
21
22// MARK: - Marks
23
24private enum Mark: Int, CaseIterable {
25 case em, strong, strike // rank order: lower = outermost
26
27 var delimiter: String {
28 switch self {
29 case .em: return "*"
30 case .strong: return "**"
31 case .strike: return "~"
32 }
33 }
34}
35
36private func marks(of style: InlineStyle) -> [Mark] {
37 var out: [Mark] = []
38 if style.italic { out.append(.em) }
39 if style.bold { out.append(.strong) }
40 if style.strike { out.append(.strike) }
41 return out
42}
43
44private func escaped(_ text: String, style: InlineStyle) -> String {
45 var out = text
46 if style.bold || style.italic { out = out.replacingOccurrences(of: "*", with: "\\*") }
47 if style.strike { out = out.replacingOccurrences(of: "~", with: "\\~") }
48 return out
49}
50
51private func isWhitespace(_ s: String) -> Bool {
52 !s.isEmpty && s.allSatisfy { $0 == " " || $0 == "\t" }
53}
54
55// MARK: - Inline state machine
56
57// Literal-mode escaping for a plain run (format-as-you-type OFF): marker
58// characters everywhere, block-forming syntax at line starts — the member's
59// typed characters must RENDER as those characters in every feed.
60private func escapeLiteralRun(_ text: String, atLineStart: Bool) -> String {
61 var out = ""
62 out.reserveCapacity(text.count)
63 for ch in text {
64 if ch == "\\" || ch == "`" || ch == "*" || ch == "_" || ch == "~" { out.append("\\") }
65 out.append(ch)
66 }
67 guard atLineStart else { return out }
68 // ^(\s*)([->+])(\s) and ^(\s*\d+)\.(\s) — the web's guardLine, hand-rolled.
69 var chars = Array(out)
70 var i = 0
71 while i < chars.count, chars[i] == " " || chars[i] == "\t" { i += 1 }
72 if i < chars.count {
73 if "->+".contains(chars[i]), i + 1 < chars.count, chars[i + 1] == " " || chars[i + 1] == "\t" {
74 chars.insert("\\", at: i)
75 } else {
76 var j = i
77 while j < chars.count, chars[j].isNumber { j += 1 }
78 if j > i, j < chars.count, chars[j] == ".", j + 1 < chars.count, chars[j + 1] == " " || chars[j + 1] == "\t" {
79 chars.insert("\\", at: j)
80 }
81 }
82 }
83 return String(chars)
84}
85
86private func renderInlines(_ nodes: [InlineNode], escapeLiterals: Bool = false, openedAtLineStart: Bool = true) -> String {
87 // Pass 1 — break-mark filter: a break keeps a mark only if the mark
88 // continues on the next node and that node is non-whitespace text.
89 var filtered: [InlineNode] = []
90 for (i, node) in nodes.enumerated() {
91 guard case .brk(let style) = node else {
92 filtered.append(node)
93 continue
94 }
95 var kept = InlineStyle.plain
96 if i + 1 < nodes.count, case .run(let next) = nodes[i + 1], !next.text.isEmpty, !isWhitespace(next.text) {
97 kept.bold = style.bold && next.style.bold
98 kept.italic = style.italic && next.style.italic
99 kept.strike = style.strike && next.style.strike
100 }
101 filtered.append(.brk(kept))
102 }
103
104 // Pass 2 — expel enclosing whitespace: a styled run's leading/trailing
105 // whitespace moves outside any mark that opens/closes at that boundary
106 // (the expelled piece keeps the marks shared with its neighbour).
107 var expelled: [InlineNode] = []
108 func styleOf(_ node: InlineNode?) -> InlineStyle {
109 switch node {
110 case .run(let r): return r.style
111 case .brk(let s): return s
112 case nil: return .plain
113 }
114 }
115 func intersection(_ a: InlineStyle, _ b: InlineStyle) -> InlineStyle {
116 InlineStyle(bold: a.bold && b.bold, italic: a.italic && b.italic, strike: a.strike && b.strike, code: a.code && b.code)
117 }
118 for (i, node) in filtered.enumerated() {
119 guard case .run(let run) = node, !run.style.isPlain, !run.style.code else {
120 expelled.append(node)
121 continue
122 }
123 var core = Substring(run.text)
124 let lead = String(core.prefix(while: { $0 == " " || $0 == "\t" }))
125 core = core.dropFirst(lead.count)
126 var trailCount = 0
127 for ch in core.reversed() {
128 if ch == " " || ch == "\t" { trailCount += 1 } else { break }
129 }
130 let trail = String(core.suffix(trailCount))
131 core = core.dropLast(trailCount)
132 let prevStyle = styleOf(i > 0 ? filtered[i - 1] : nil)
133 let nextStyle = styleOf(i + 1 < filtered.count ? filtered[i + 1] : nil)
134 if !lead.isEmpty { expelled.append(.run(RichRun(lead, style: intersection(run.style, prevStyle)))) }
135 if !core.isEmpty { expelled.append(.run(RichRun(String(core), style: run.style))) }
136 if !trail.isEmpty { expelled.append(.run(RichRun(trail, style: intersection(run.style, nextStyle)))) }
137 }
138
139 // Pass 3 — emit with an active-mark stack. Marks already open stay open
140 // when the new node still wants them (longest keepable prefix of the
141 // stack); the rest close top-down; new marks open in rank order.
142 var out = ""
143 var active: [Mark] = []
144 func closeDownTo(_ desired: [Mark]) {
145 var keep = 0
146 while keep < active.count, desired.contains(active[keep]) { keep += 1 }
147 while active.count > keep {
148 out += active.removeLast().delimiter
149 }
150 }
151 func open(_ desired: [Mark]) {
152 for mark in desired where !active.contains(mark) {
153 out += mark.delimiter
154 active.append(mark)
155 }
156 }
157 for node in expelled {
158 switch node {
159 case .run(let run) where run.style.code:
160 closeDownTo([])
161 // Delimiters sized past the content's longest backtick run, with
162 // one space of padding whenever the content holds any backtick.
163 var longest = 0
164 var current = 0
165 for ch in run.text {
166 current = ch == "`" ? current + 1 : 0
167 longest = max(longest, current)
168 }
169 let delim = String(repeating: "`", count: longest + 1)
170 let pad = longest > 0 ? " " : ""
171 out += delim + pad + run.text + pad + delim
172 case .run(let run):
173 let desired = marks(of: run.style)
174 closeDownTo(desired)
175 open(desired)
176 if escapeLiterals, run.style.isPlain {
177 out += escapeLiteralRun(run.text, atLineStart: out.isEmpty ? openedAtLineStart : out.hasSuffix("\n"))
178 } else {
179 out += escaped(run.text, style: run.style)
180 }
181 case .brk(let style):
182 closeDownTo(marks(of: style))
183 out += "\n"
184 }
185 }
186 closeDownTo([])
187 return out
188}
189
190// MARK: - Blocks
191
192public func serializeComposer(_ blocks: [ComposerBlock], escapeLiterals: Bool = false) -> String {
193 var parts: [String] = []
194 for block in blocks {
195 switch block {
196 case .paragraph(let nodes):
197 parts.append(renderInlines(nodes, escapeLiterals: escapeLiterals))
198 case .quote(let nodes):
199 // "> " before every emitted line — including empty ones, which
200 // keep the trailing space ("> a\n> \n> b"), matching the web.
201 let body = renderInlines(nodes)
202 parts.append(body.split(separator: "\n", omittingEmptySubsequences: false).map { "> " + $0 }.joined(separator: "\n"))
203 case .bullet(let items):
204 parts.append(items.map { "- " + renderInlines($0.map { .run($0) }) }.joined(separator: "\n"))
205 case .ordered(let start, let items):
206 let width = String(start + items.count - 1).count
207 parts.append(items.enumerated().map { i, runs in
208 let num = String(start + i)
209 return String(repeating: " ", count: width - num.count) + num + ". " + renderInlines(runs.map { .run($0) })
210 }.joined(separator: "\n"))
211 case .fence(let lang, let text):
212 // Opening run sized past any ≥3-backtick run inside; tildes and
213 // shorter runs never grow it. Empty fences collapse to two lines.
214 var longest = 0
215 var current = 0
216 for ch in text {
217 current = ch == "`" ? current + 1 : 0
218 if current >= 3 { longest = max(longest, current) }
219 }
220 let delim = String(repeating: "`", count: max(3, longest == 0 ? 0 : longest + 1))
221 parts.append(text.isEmpty ? delim + lang + "\n" + delim : delim + lang + "\n" + text + "\n" + delim)
222 }
223 }
224 return parts.joined(separator: "\n\n")
225}
226