gitbay/Views/Repos/MarkdownView.swift
354 lines · 13762 bytes
1import SwiftUI
2
3/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
4/// tables, paragraphs. Inline styling comes from AttributedString's own markdown
5/// parsing; blocks are split here because it only does inline.
6struct MarkdownView: View {
7
8 let markdown: String
9
10 var body: some View {
11 VStack(alignment: .leading, spacing: 12) {
12 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
13 blockView(block)
14 }
15 }
16 }
17
18 // MARK: - Block model
19
20 enum Block: Equatable {
21 case heading(level: Int, text: String)
22 case code(String)
23 case quote(String)
24 case bullet([String])
25 case ordered([String])
26 case table(Table)
27 case rule
28 case paragraph(String)
29 }
30
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] {
44 var blocks: [Block] = []
45 var lines = markdown.components(separatedBy: "\n")[...]
46
47 while let line = lines.first {
48 let trimmed = line.trimmingCharacters(in: .whitespaces)
49
50 if trimmed.hasPrefix("```") {
51 lines = lines.dropFirst()
52 var code: [String] = []
53 while let next = lines.first,
54 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
55 code.append(next)
56 lines = lines.dropFirst()
57 }
58 lines = lines.dropFirst() // closing fence
59 blocks.append(.code(code.joined(separator: "\n")))
60 continue
61 }
62 if trimmed.hasPrefix("#") {
63 let level = trimmed.prefix(while: { $0 == "#" }).count
64 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
65 blocks.append(.heading(level: min(level, 4), text: text))
66 lines = lines.dropFirst()
67 continue
68 }
69 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
70 blocks.append(.rule)
71 lines = lines.dropFirst()
72 continue
73 }
74 if trimmed.hasPrefix(">") {
75 var quote: [String] = []
76 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
77 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
78 lines = lines.dropFirst()
79 }
80 blocks.append(.quote(quote.joined(separator: " ")))
81 continue
82 }
83 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
84 var items: [String] = []
85 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
86 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
87 items.append(String(next.dropFirst(2)))
88 lines = lines.dropFirst()
89 }
90 blocks.append(.bullet(items))
91 continue
92 }
93 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
94 var items: [String] = []
95 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
96 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
97 items.append(String(next[range.upperBound...]))
98 lines = lines.dropFirst()
99 }
100 blocks.append(.ordered(items))
101 continue
102 }
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 }
119 if trimmed.isEmpty {
120 lines = lines.dropFirst()
121 continue
122 }
123 // Paragraph: consume until a blank line or another block start.
124 var paragraph: [String] = []
125 while let next = lines.first {
126 let t = next.trimmingCharacters(in: .whitespaces)
127 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
128 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
129 break
130 }
131 paragraph.append(t)
132 lines = lines.dropFirst()
133 }
134 blocks.append(.paragraph(paragraph.joined(separator: " ")))
135 }
136 return blocks
137 }
138
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
193 // MARK: - Rendering
194
195 @ViewBuilder
196 private func blockView(_ block: Block) -> some View {
197 switch block {
198 case .heading(let level, let text):
199 Self.inline(text, codeStyle: headingCodeStyle(level))
200 .font(headingFont(level))
201 .padding(.top, level <= 2 ? 8 : 4)
202 case .code(let code):
203 ScrollView(.horizontal) {
204 Text(code)
205 .font(.gbMono(.caption))
206 .padding(10)
207 }
208 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
209 case .quote(let text):
210 HStack(spacing: 10) {
211 RoundedRectangle(cornerRadius: 2)
212 .fill(.tertiary)
213 .frame(width: 3)
214 Self.inline(text).foregroundStyle(.secondary)
215 }
216 .fixedSize(horizontal: false, vertical: true)
217 case .bullet(let items):
218 VStack(alignment: .leading, spacing: 4) {
219 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
220 HStack(alignment: .firstTextBaseline, spacing: 8) {
221 Text("•")
222 Self.inline(item)
223 }
224 }
225 }
226 case .ordered(let items):
227 VStack(alignment: .leading, spacing: 4) {
228 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
229 HStack(alignment: .firstTextBaseline, spacing: 8) {
230 Text("\(index + 1).").monospacedDigit()
231 Self.inline(item)
232 }
233 }
234 }
235 case .table(let table):
236 MarkdownTableView(table: table)
237 case .rule:
238 Divider()
239 case .paragraph(let text):
240 Self.inline(text)
241 }
242 }
243
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(
250 markdown: text,
251 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
252 ) else {
253 return Text(text)
254 }
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)
260 }
261
262 private func headingFont(_ level: Int) -> Font {
263 switch level {
264 case 1: .title2.bold()
265 case 2: .title3.bold()
266 case 3: .headline
267 default: .subheadline.bold()
268 }
269 }
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 }
354}