| 1 | import 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 | |
| 24 | private 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 | |
| 36 | private 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 | |
| 44 | private 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 | |
| 51 | private 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. |
| 60 | private 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 | |
| 86 | private 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 | |
| 192 | public 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 | |