Commit 47eb92311e

47eb92311eb1a6da7d81cbfbb765e32421b4a180

parent: 099872b07a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-23 03:24 UTC

Markdown list items keep their continuation lines

A non-blank line under a list item that starts no other block joins
the item, indented or not, as in CommonMark. Item text no longer
truncates to one line at some widths.

Layout: unified · split

gitbay/Views/Repos/MarkdownView.swift +31 −6
@@ -86,9 +86,14 @@ struct MarkdownView: View {
86 } 86 }
87 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { 87 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
88 var items: [String] = [] 88 var items: [String] = []
89 while let next = lines.first?.trimmingCharacters(in: .whitespaces), 89 while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
90 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") { 90 if next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
91 items.append(String(next.dropFirst(2))) 91 items.append(String(next.dropFirst(2)))
92 } else if Self.continuesItem(lines) {
93 items[items.count - 1] += " " + next
94 } else {
95 break
96 }
92 lines = lines.dropFirst() 97 lines = lines.dropFirst()
93 } 98 }
94 blocks.append(.bullet(items)) 99 blocks.append(.bullet(items))
@@ -96,9 +101,14 @@ struct MarkdownView: View {
96 } 101 }
97 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil { 102 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
98 var items: [String] = [] 103 var items: [String] = []
99 while let next = lines.first?.trimmingCharacters(in: .whitespaces), 104 while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
100 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) { 105 if let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
101 items.append(String(next[range.upperBound...])) 106 items.append(String(next[range.upperBound...]))
107 } else if Self.continuesItem(lines) {
108 items[items.count - 1] += " " + next
109 } else {
110 break
111 }
102 lines = lines.dropFirst() 112 lines = lines.dropFirst()
103 } 113 }
104 blocks.append(.ordered(items)) 114 blocks.append(.ordered(items))
@@ -140,6 +150,19 @@ struct MarkdownView: View {
140 return blocks 150 return blocks
141 } 151 }
142 152
153 /// A line that wraps the list item above it: anything but a blank line or
154 /// the start of another block, indented or not, as in CommonMark.
155 private static func continuesItem(_ lines: ArraySlice<String>) -> Bool {
156 guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty
157 else { return false }
158 let startsBlock = line.hasPrefix("#") || line.hasPrefix("```") || line.hasPrefix(">")
159 || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ")
160 || line == "---" || line == "***" || line == "___"
161 || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil
162 || tableStart(lines) != nil
163 return !startsBlock
164 }
165
143 // MARK: - Tables 166 // MARK: - Tables
144 167
145 /// A table begins where a row of cells is followed by a separator row with the 168 /// A table begins where a row of cells is followed by a separator row with the
@@ -224,6 +247,7 @@ struct MarkdownView: View {
224 HStack(alignment: .firstTextBaseline, spacing: 8) { 247 HStack(alignment: .firstTextBaseline, spacing: 8) {
225 Text("•") 248 Text("•")
226 Self.inline(item, linkOptions: linkOptions) 249 Self.inline(item, linkOptions: linkOptions)
250 .fixedSize(horizontal: false, vertical: true)
227 } 251 }
228 } 252 }
229 } 253 }
@@ -233,6 +257,7 @@ struct MarkdownView: View {
233 HStack(alignment: .firstTextBaseline, spacing: 8) { 257 HStack(alignment: .firstTextBaseline, spacing: 8) {
234 Text("\(index + 1).").monospacedDigit() 258 Text("\(index + 1).").monospacedDigit()
235 Self.inline(item, linkOptions: linkOptions) 259 Self.inline(item, linkOptions: linkOptions)
260 .fixedSize(horizontal: false, vertical: true)
236 } 261 }
237 } 262 }
238 } 263 }
gitbayTests/MarkdownTests.swift +40
@@ -135,3 +135,43 @@ struct MarkdownLinkTests {
135 == ["ARCHITECTURE.md"]) 135 == ["ARCHITECTURE.md"])
136 } 136 }
137} 137}
138
139@MainActor
140struct MarkdownListTests {
141
142 private func blocks(_ markdown: String) -> [MarkdownView.Block] {
143 MarkdownView(markdown: markdown).blocks
144 }
145
146 @Test func indentedContinuationLinesStayInTheItem() {
147 let parsed = blocks("""
148 - Org source files in `content/`.
149 That directory is the URL root.
150 - HTML templates.
151 """)
152 #expect(parsed == [.bullet([
153 "Org source files in `content/`. That directory is the URL root.",
154 "HTML templates.",
155 ])])
156 }
157
158 @Test func unindentedContinuationLinesStayInTheItem() {
159 let parsed = blocks("- one\ncontinued\n- two")
160 #expect(parsed == [.bullet(["one continued", "two"])])
161 }
162
163 @Test func orderedItemsTakeContinuationLines() {
164 let parsed = blocks("1. first\n more\n2. second")
165 #expect(parsed == [.ordered(["first more", "second"])])
166 }
167
168 @Test func aListEndsAtABlankLine() {
169 let parsed = blocks("- one\n\nAfter the list.")
170 #expect(parsed == [.bullet(["one"]), .paragraph("After the list.")])
171 }
172
173 @Test func aHeadingEndsAList() {
174 let parsed = blocks("- one\n## Next")
175 #expect(parsed == [.bullet(["one"]), .heading(level: 2, text: "Next")])
176 }
177}