Commit 385ad813bb

385ad813bb1a50050a9c2f309762845c78a21b19

parent: 9a3cb9402a

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 02:59 UTC

Markdown: a heading needs a space after its #s (!144)

A line like `#12 fixed this` rendered as a heading and lost its reference link. ATX headings now need one to six `#` followed by a space, tab or end of line (CommonMark); paragraph and list-item continuation use the same check. 5 new tests in MarkdownHeadingTests.

Layout: unified · split

gitbay/Views/Repos/MarkdownView.swift +13 −5
@@ -65,9 +65,7 @@ struct MarkdownView: View {
65 blocks.append(.code(code.joined(separator: "\n"))) 65 blocks.append(.code(code.joined(separator: "\n")))
66 continue 66 continue
67 } 67 }
68 if trimmed.hasPrefix("#") { 68 if let (level, text) = Self.atxHeading(trimmed) {
69 let level = trimmed.prefix(while: { $0 == "#" }).count
70 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
71 blocks.append(.heading(level: min(level, 4), text: text)) 69 blocks.append(.heading(level: min(level, 4), text: text))
72 lines = lines.dropFirst() 70 lines = lines.dropFirst()
73 continue 71 continue
@@ -140,7 +138,7 @@ struct MarkdownView: View {
140 var paragraph: [String] = [] 138 var paragraph: [String] = []
141 while let next = lines.first { 139 while let next = lines.first {
142 let t = next.trimmingCharacters(in: .whitespaces) 140 let t = next.trimmingCharacters(in: .whitespaces)
143 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">") 141 if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">")
144 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil { 142 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
145 break 143 break
146 } 144 }
@@ -152,12 +150,22 @@ struct MarkdownView: View {
152 return blocks 150 return blocks
153 } 151 }
154 152
153 /// An ATX heading: one to six `#` followed by a space, a tab or the end
154 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
155 private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
156 let level = line.prefix(while: { $0 == "#" }).count
157 guard (1...6).contains(level) else { return nil }
158 let rest = line.dropFirst(level)
159 guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
160 return (level, rest.trimmingCharacters(in: .whitespaces))
161 }
162
155 /// A line that wraps the list item above it: anything but a blank line or 163 /// A line that wraps the list item above it: anything but a blank line or
156 /// the start of another block, indented or not, as in CommonMark. 164 /// the start of another block, indented or not, as in CommonMark.
157 private static func continuesItem(_ lines: ArraySlice<String>) -> Bool { 165 private static func continuesItem(_ lines: ArraySlice<String>) -> Bool {
158 guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty 166 guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty
159 else { return false } 167 else { return false }
160 let startsBlock = line.hasPrefix("#") || line.hasPrefix("```") || line.hasPrefix(">") 168 let startsBlock = atxHeading(line) != nil || line.hasPrefix("```") || line.hasPrefix(">")
161 || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") 169 || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ")
162 || line == "---" || line == "***" || line == "___" 170 || line == "---" || line == "***" || line == "___"
163 || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil 171 || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil
gitbayTests/MarkdownTests.swift +29
@@ -175,3 +175,32 @@ struct MarkdownListTests {
175 #expect(parsed == [.bullet(["one"]), .heading(level: 2, text: "Next")]) 175 #expect(parsed == [.bullet(["one"]), .heading(level: 2, text: "Next")])
176 } 176 }
177} 177}
178
179@MainActor
180struct MarkdownHeadingTests {
181
182 private func blocks(_ markdown: String) -> [MarkdownView.Block] {
183 MarkdownView(markdown: markdown).blocks
184 }
185
186 @Test func hashWithoutSpaceIsAParagraph() {
187 #expect(blocks("#12 fixed this") == [.paragraph("#12 fixed this")])
188 }
189
190 @Test func hashReferenceDoesNotEndAParagraph() {
191 #expect(blocks("see\n#12 for context") == [.paragraph("see #12 for context")])
192 }
193
194 @Test func hashReferenceContinuesAListItem() {
195 #expect(blocks("- fixed in\n#12") == blocks("- fixed in #12"))
196 }
197
198 @Test func headingNeedsSpaceOrEndOfLine() {
199 #expect(blocks("## Usage") == [.heading(level: 2, text: "Usage")])
200 #expect(blocks("#") == [.heading(level: 1, text: "")])
201 }
202
203 @Test func sevenHashesIsAParagraph() {
204 #expect(blocks("####### x") == [.paragraph("####### x")])
205 }
206}