Commit 19bbcd6de2

19bbcd6de2ba0fe5195853c32836627c1f780be7

parent: 447c294421

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-31 22:41 UTC

Markdown READMEs render tables, and inline code at the web's size

GFM tables were falling through to the paragraph branch, so a table
arrived as one run-on line of pipes. They now parse as a block and
render in a Grid: header band, separator-row alignment, ragged rows
padded. A multi-column table lays out two columns to a screen and
scrolls sideways beyond that, so cells wrap rather than run off the edge.

Inline code was left to the substitution AttributedString makes for a
custom face, which came out larger than the surrounding sans. It is now
set to the bundled mono a step below the block's own style, the ratio
the web uses.

Layout: unified · split

gitbay/Views/Repos/MarkdownView.swift +191 −14
@@ -1,7 +1,7 @@
11import SwiftUI
22
33/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
4/// paragraphs. Inline styling comes from AttributedString's own markdown
4/// tables, paragraphs. Inline styling comes from AttributedString's own markdown
55/// parsing; blocks are split here because it only does inline.
66struct MarkdownView: View {
77
@@ -17,17 +17,30 @@ struct MarkdownView: View {
1717
1818 // MARK: - Block model
1919
20 private enum Block {
20 enum Block: Equatable {
2121 case heading(level: Int, text: String)
2222 case code(String)
2323 case quote(String)
2424 case bullet([String])
2525 case ordered([String])
26 case table(Table)
2627 case rule
2728 case paragraph(String)
2829 }
2930
30 private var blocks: [Block] {
31 /// A GitHub-flavored table: a header row, a body, and one alignment per
32 /// column taken from the `|:---:|` separator row.
33 struct Table: Equatable {
34 var header: [String]
35 var rows: [[String]]
36 var alignments: [Alignment]
37
38 enum Alignment: Equatable { case leading, center, trailing }
39
40 var columnCount: Int { header.count }
41 }
42
43 var blocks: [Block] {
3144 var blocks: [Block] = []
3245 var lines = markdown.components(separatedBy: "\n")[...]
3346
@@ -87,6 +100,22 @@ struct MarkdownView: View {
87100 blocks.append(.ordered(items))
88101 continue
89102 }
103 if let table = Self.tableStart(lines) {
104 lines = lines.dropFirst(2) // header and separator
105 var rows: [[String]] = []
106 while let next = lines.first, next.contains("|") {
107 var cells = Self.cells(next)
108 // Ragged rows are padded, not dropped, so a short row leaves
109 // blanks rather than shifting the grid.
110 cells += Array(repeating: "", count: max(0, table.header.count - cells.count))
111 rows.append(Array(cells.prefix(table.header.count)))
112 lines = lines.dropFirst()
113 }
114 blocks.append(.table(Table(
115 header: table.header, rows: rows, alignments: table.alignments
116 )))
117 continue
118 }
90119 if trimmed.isEmpty {
91120 lines = lines.dropFirst()
92121 continue
@@ -96,7 +125,7 @@ struct MarkdownView: View {
96125 while let next = lines.first {
97126 let t = next.trimmingCharacters(in: .whitespaces)
98127 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
99 || t.hasPrefix("- ") || t.hasPrefix("* ") {
128 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
100129 break
101130 }
102131 paragraph.append(t)
@@ -107,13 +136,67 @@ struct MarkdownView: View {
107136 return blocks
108137 }
109138
139 // MARK: - Tables
140
141 /// A table begins where a row of cells is followed by a separator row with the
142 /// same number of cells. Anything else containing a pipe stays a paragraph.
143 private static func tableStart(
144 _ lines: ArraySlice<String>
145 ) -> (header: [String], alignments: [Table.Alignment])? {
146 guard let head = lines.first, head.contains("|"),
147 let separator = lines.dropFirst().first
148 else { return nil }
149 let header = cells(head)
150 let rules = cells(separator)
151 guard header.count == rules.count, !header.isEmpty,
152 rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil })
153 else { return nil }
154 return (header, rules.map(alignment))
155 }
156
157 private static func alignment(of rule: String) -> Table.Alignment {
158 switch (rule.hasPrefix(":"), rule.hasSuffix(":")) {
159 case (true, true): .center
160 case (false, true): .trailing
161 default: .leading
162 }
163 }
164
165 /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and
166 /// a `\|` stays escaped so the inline parser turns it into a literal pipe.
167 private static func cells(_ line: String) -> [String] {
168 var row = Substring(line.trimmingCharacters(in: .whitespaces))
169 if row.hasPrefix("|") { row = row.dropFirst() }
170 if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() }
171
172 var cells: [String] = []
173 var current = ""
174 var escaped = false
175 for character in row {
176 if escaped {
177 current.append(character)
178 escaped = false
179 } else if character == "\\" {
180 current.append(character)
181 escaped = true
182 } else if character == "|" {
183 cells.append(current)
184 current = ""
185 } else {
186 current.append(character)
187 }
188 }
189 cells.append(current)
190 return cells.map { $0.trimmingCharacters(in: .whitespaces) }
191 }
192
110193 // MARK: - Rendering
111194
112195 @ViewBuilder
113196 private func blockView(_ block: Block) -> some View {
114197 switch block {
115198 case .heading(let level, let text):
116 inline(text)
199 Self.inline(text, codeStyle: headingCodeStyle(level))
117200 .font(headingFont(level))
118201 .padding(.top, level <= 2 ? 8 : 4)
119202 case .code(let code):
@@ -128,7 +211,7 @@ struct MarkdownView: View {
128211 RoundedRectangle(cornerRadius: 2)
129212 .fill(.tertiary)
130213 .frame(width: 3)
131 inline(text).foregroundStyle(.secondary)
214 Self.inline(text).foregroundStyle(.secondary)
132215 }
133216 .fixedSize(horizontal: false, vertical: true)
134217 case .bullet(let items):
@@ -136,7 +219,7 @@ struct MarkdownView: View {
136219 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
137220 HStack(alignment: .firstTextBaseline, spacing: 8) {
138221 Text("•")
139 inline(item)
222 Self.inline(item)
140223 }
141224 }
142225 }
@@ -145,25 +228,35 @@ struct MarkdownView: View {
145228 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
146229 HStack(alignment: .firstTextBaseline, spacing: 8) {
147230 Text("\(index + 1).").monospacedDigit()
148 inline(item)
231 Self.inline(item)
149232 }
150233 }
151234 }
235 case .table(let table):
236 MarkdownTableView(table: table)
152237 case .rule:
153238 Divider()
154239 case .paragraph(let text):
155 inline(text)
240 Self.inline(text)
156241 }
157242 }
158243
159 private func inline(_ text: String) -> Text {
160 if let attributed = try? AttributedString(
244 /// Inline code is set explicitly rather than left to the substitution
245 /// AttributedString makes for a custom face, which comes out optically larger
246 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
247 /// the ratio the web uses (`--fs-1` against `--fs-3`).
248 static func inline(_ text: String, codeStyle: Font.TextStyle = .subheadline) -> Text {
249 guard var attributed = try? AttributedString(
161250 markdown: text,
162251 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
163 ) {
164 return Text(attributed)
252 ) else {
253 return Text(text)
165254 }
166 return Text(text)
255 for range in attributed.runs.map(\.range)
256 where attributed[range].inlinePresentationIntent?.contains(.code) == true {
257 attributed[range].font = .gbMono(codeStyle)
258 }
259 return Text(attributed)
167260 }
168261
169262 private func headingFont(_ level: Int) -> Font {
@@ -174,4 +267,88 @@ struct MarkdownView: View {
174267 default: .subheadline.bold()
175268 }
176269 }
270
271 /// One step below the heading's own size, so code in a heading tracks it.
272 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
273 switch level {
274 case 1: .title3
275 case 2: .headline
276 case 3: .subheadline
277 default: .footnote
278 }
279 }
280}
281
282/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
283///
284/// A multi-column table is laid out two columns to a screen, so long prose wraps
285/// inside its cell instead of running off the edge and wider tables scroll. A cell
286/// needs a width proposed to it, not a cap: with only a cap it reports the height of
287/// a single line and rows overlap.
288private struct MarkdownTableView: View {
289 let table: MarkdownView.Table
290
291 private static let spacing: CGFloat = 14
292
293 @State private var containerWidth: CGFloat = 0
294
295 private var cellWidth: CGFloat? {
296 guard containerWidth > 0, table.columnCount > 1 else { return nil }
297 return (containerWidth - Self.spacing) / 2
298 }
299
300 var body: some View {
301 ScrollView(.horizontal, showsIndicators: false) {
302 Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) {
303 GridRow {
304 ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in
305 cell(text, column).bold()
306 }
307 }
308 Divider().gridCellColumns(table.columnCount)
309 ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
310 GridRow {
311 ForEach(Array(row.enumerated()), id: \.offset) { column, text in
312 cell(text, column)
313 }
314 }
315 }
316 }
317 .padding(.vertical, 2)
318 }
319 .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 }
320 }
321
322 private func cell(_ text: String, _ column: Int) -> some View {
323 let alignment = table.alignments[column]
324 return MarkdownView.inline(text)
325 .multilineTextAlignment(Self.textAlignment(alignment))
326 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
327 .fixedSize(horizontal: cellWidth == nil, vertical: true)
328 .gridColumnAlignment(Self.gridAlignment(alignment))
329 }
330
331 private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment {
332 switch alignment {
333 case .leading: .leading
334 case .center: .center
335 case .trailing: .trailing
336 }
337 }
338
339 private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment {
340 switch alignment {
341 case .leading: .leading
342 case .center: .center
343 case .trailing: .trailing
344 }
345 }
346
347 private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment {
348 switch alignment {
349 case .leading: .leading
350 case .center: .center
351 case .trailing: .trailing
352 }
353 }
177354}
gitbayTests/MarkdownTests.swift added +92
@@ -0,0 +1,92 @@
1import Testing
2@testable import gitbay
3
4@MainActor
5struct MarkdownTableTests {
6
7 private func blocks(_ markdown: String) -> [MarkdownView.Block] {
8 MarkdownView(markdown: markdown).blocks
9 }
10
11 @Test func headerSeparatorAndBodyBecomeATable() {
12 let parsed = blocks("""
13 | Add | Documented in |
14 |---|---|
15 | An RSS feed | [Collections](https://orgo.krz.sh/) |
16 """)
17 #expect(parsed == [.table(MarkdownView.Table(
18 header: ["Add", "Documented in"],
19 rows: [["An RSS feed", "[Collections](https://orgo.krz.sh/)"]],
20 alignments: [.leading, .leading]
21 ))])
22 }
23
24 @Test func alignmentComesFromTheSeparatorRow() {
25 let parsed = blocks("""
26 | l | c | r |
27 |:--|:-:|--:|
28 | a | b | c |
29 """)
30 guard case .table(let table) = parsed.first else {
31 Issue.record("expected a table, got \(parsed)")
32 return
33 }
34 #expect(table.alignments == [.leading, .center, .trailing])
35 }
36
37 @Test func outerPipesAreOptionalAndEscapedPipesStayInTheCell() {
38 let parsed = blocks("""
39 a | b
40 --- | ---
41 x \\| y | z
42 """)
43 guard case .table(let table) = parsed.first else {
44 Issue.record("expected a table, got \(parsed)")
45 return
46 }
47 #expect(table.header == ["a", "b"])
48 #expect(table.rows == [["x \\| y", "z"]])
49 }
50
51 @Test func raggedRowsArePaddedToTheHeaderWidth() {
52 let parsed = blocks("""
53 | a | b | c |
54 |---|---|---|
55 | 1 |
56 | 1 | 2 | 3 | 4 |
57 """)
58 guard case .table(let table) = parsed.first else {
59 Issue.record("expected a table, got \(parsed)")
60 return
61 }
62 #expect(table.rows == [["1", "", ""], ["1", "2", "3"]])
63 }
64
65 @Test func aTableEndsAtABlankLineAndTheTextAfterItIsAParagraph() {
66 let parsed = blocks("""
67 | a |
68 |---|
69 | 1 |
70
71 After the table.
72 """)
73 #expect(parsed.count == 2)
74 #expect(parsed.last == .paragraph("After the table."))
75 }
76
77 @Test func aTableInterruptsThePrecedingParagraph() {
78 let parsed = blocks("""
79 Each of these has a page in the guide:
80 | a |
81 |---|
82 | 1 |
83 """)
84 #expect(parsed.first == .paragraph("Each of these has a page in the guide:"))
85 #expect(parsed.count == 2)
86 }
87
88 @Test func aPipeInProseIsNotATable() {
89 let parsed = blocks("Run `a | b` to pipe.\nIt is not a table.")
90 #expect(parsed == [.paragraph("Run `a | b` to pipe. It is not a table.")])
91 }
92}