gitbay/Views/Repos/MarkdownView.swift
380 lines · 15070 bytes
1import OrgSwift
2import SwiftUI
3
4/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
5/// tables, paragraphs. Inline styling comes from AttributedString's own markdown
6/// parsing; blocks are split here because it only does inline.
7struct MarkdownView: View {
8
9 let markdown: String
10 /// Repository context for resolving relative link targets. Nil leaves them as authored,
11 /// which is right for a body with no file to be relative to.
12 var linkOptions: OrgRenderOptions?
13
14 var body: some View {
15 VStack(alignment: .leading, spacing: 12) {
16 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
17 blockView(block)
18 }
19 }
20 }
21
22 // MARK: - Block model
23
24 enum Block: Equatable {
25 case heading(level: Int, text: String)
26 case code(String)
27 case quote(String)
28 case bullet([String])
29 case ordered([String])
30 case table(Table)
31 case rule
32 case paragraph(String)
33 }
34
35 /// A GitHub-flavored table: a header row, a body, and one alignment per
36 /// column taken from the `|:---:|` separator row.
37 struct Table: Equatable {
38 var header: [String]
39 var rows: [[String]]
40 var alignments: [Alignment]
41
42 enum Alignment: Equatable { case leading, center, trailing }
43
44 var columnCount: Int { header.count }
45 }
46
47 var blocks: [Block] {
48 var blocks: [Block] = []
49 var lines = markdown.components(separatedBy: "\n")[...]
50
51 while let line = lines.first {
52 let trimmed = line.trimmingCharacters(in: .whitespaces)
53
54 if trimmed.hasPrefix("```") {
55 lines = lines.dropFirst()
56 var code: [String] = []
57 while let next = lines.first,
58 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
59 code.append(next)
60 lines = lines.dropFirst()
61 }
62 lines = lines.dropFirst() // closing fence
63 blocks.append(.code(code.joined(separator: "\n")))
64 continue
65 }
66 if trimmed.hasPrefix("#") {
67 let level = trimmed.prefix(while: { $0 == "#" }).count
68 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
69 blocks.append(.heading(level: min(level, 4), text: text))
70 lines = lines.dropFirst()
71 continue
72 }
73 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
74 blocks.append(.rule)
75 lines = lines.dropFirst()
76 continue
77 }
78 if trimmed.hasPrefix(">") {
79 var quote: [String] = []
80 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
81 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
82 lines = lines.dropFirst()
83 }
84 blocks.append(.quote(quote.joined(separator: " ")))
85 continue
86 }
87 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
88 var items: [String] = []
89 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
90 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
91 items.append(String(next.dropFirst(2)))
92 lines = lines.dropFirst()
93 }
94 blocks.append(.bullet(items))
95 continue
96 }
97 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
98 var items: [String] = []
99 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
100 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
101 items.append(String(next[range.upperBound...]))
102 lines = lines.dropFirst()
103 }
104 blocks.append(.ordered(items))
105 continue
106 }
107 if let table = Self.tableStart(lines) {
108 lines = lines.dropFirst(2) // header and separator
109 var rows: [[String]] = []
110 while let next = lines.first, next.contains("|") {
111 var cells = Self.cells(next)
112 // Ragged rows are padded, not dropped, so a short row leaves
113 // blanks rather than shifting the grid.
114 cells += Array(repeating: "", count: max(0, table.header.count - cells.count))
115 rows.append(Array(cells.prefix(table.header.count)))
116 lines = lines.dropFirst()
117 }
118 blocks.append(.table(Table(
119 header: table.header, rows: rows, alignments: table.alignments
120 )))
121 continue
122 }
123 if trimmed.isEmpty {
124 lines = lines.dropFirst()
125 continue
126 }
127 // Paragraph: consume until a blank line or another block start.
128 var paragraph: [String] = []
129 while let next = lines.first {
130 let t = next.trimmingCharacters(in: .whitespaces)
131 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
132 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
133 break
134 }
135 paragraph.append(t)
136 lines = lines.dropFirst()
137 }
138 blocks.append(.paragraph(paragraph.joined(separator: " ")))
139 }
140 return blocks
141 }
142
143 // MARK: - Tables
144
145 /// A table begins where a row of cells is followed by a separator row with the
146 /// same number of cells. Anything else containing a pipe stays a paragraph.
147 private static func tableStart(
148 _ lines: ArraySlice<String>
149 ) -> (header: [String], alignments: [Table.Alignment])? {
150 guard let head = lines.first, head.contains("|"),
151 let separator = lines.dropFirst().first
152 else { return nil }
153 let header = cells(head)
154 let rules = cells(separator)
155 guard header.count == rules.count, !header.isEmpty,
156 rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil })
157 else { return nil }
158 return (header, rules.map(alignment))
159 }
160
161 private static func alignment(of rule: String) -> Table.Alignment {
162 switch (rule.hasPrefix(":"), rule.hasSuffix(":")) {
163 case (true, true): .center
164 case (false, true): .trailing
165 default: .leading
166 }
167 }
168
169 /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and
170 /// a `\|` stays escaped so the inline parser turns it into a literal pipe.
171 private static func cells(_ line: String) -> [String] {
172 var row = Substring(line.trimmingCharacters(in: .whitespaces))
173 if row.hasPrefix("|") { row = row.dropFirst() }
174 if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() }
175
176 var cells: [String] = []
177 var current = ""
178 var escaped = false
179 for character in row {
180 if escaped {
181 current.append(character)
182 escaped = false
183 } else if character == "\\" {
184 current.append(character)
185 escaped = true
186 } else if character == "|" {
187 cells.append(current)
188 current = ""
189 } else {
190 current.append(character)
191 }
192 }
193 cells.append(current)
194 return cells.map { $0.trimmingCharacters(in: .whitespaces) }
195 }
196
197 // MARK: - Rendering
198
199 @ViewBuilder
200 private func blockView(_ block: Block) -> some View {
201 switch block {
202 case .heading(let level, let text):
203 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions)
204 .font(headingFont(level))
205 .padding(.top, level <= 2 ? 8 : 4)
206 case .code(let code):
207 ScrollView(.horizontal) {
208 Text(code)
209 .font(.gbMono(.caption))
210 .padding(10)
211 }
212 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
213 case .quote(let text):
214 HStack(spacing: 10) {
215 RoundedRectangle(cornerRadius: 2)
216 .fill(.tertiary)
217 .frame(width: 3)
218 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary)
219 }
220 .fixedSize(horizontal: false, vertical: true)
221 case .bullet(let items):
222 VStack(alignment: .leading, spacing: 4) {
223 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
224 HStack(alignment: .firstTextBaseline, spacing: 8) {
225 Text("•")
226 Self.inline(item, linkOptions: linkOptions)
227 }
228 }
229 }
230 case .ordered(let items):
231 VStack(alignment: .leading, spacing: 4) {
232 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
233 HStack(alignment: .firstTextBaseline, spacing: 8) {
234 Text("\(index + 1).").monospacedDigit()
235 Self.inline(item, linkOptions: linkOptions)
236 }
237 }
238 }
239 case .table(let table):
240 MarkdownTableView(table: table, linkOptions: linkOptions)
241 case .rule:
242 Divider()
243 case .paragraph(let text):
244 Self.inline(text, linkOptions: linkOptions)
245 }
246 }
247
248 /// Inline code is set explicitly rather than left to the substitution
249 /// AttributedString makes for a custom face, which comes out optically larger
250 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
251 /// the ratio the web uses (`--fs-1` against `--fs-3`).
252 static func inline(
253 _ text: String,
254 codeStyle: Font.TextStyle = .subheadline,
255 linkOptions: OrgRenderOptions? = nil
256 ) -> Text {
257 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions))
258 }
259
260 static func attributed(
261 _ text: String,
262 codeStyle: Font.TextStyle = .subheadline,
263 linkOptions: OrgRenderOptions? = nil
264 ) -> AttributedString {
265 guard var attributed = try? AttributedString(
266 markdown: text,
267 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
268 ) else {
269 return AttributedString(text)
270 }
271 for range in attributed.runs.map(\.range) {
272 if attributed[range].inlinePresentationIntent?.contains(.code) == true {
273 attributed[range].font = .gbMono(codeStyle)
274 }
275 // `[x](ARCHITECTURE.md)` parses to a scheme-less URL that `openURL` cannot open,
276 // so the text renders as a link and the tap does nothing. Resolve through the
277 // same options the org renderer uses, so both formats reach the same file page.
278 if let link = attributed[range].link,
279 let resolved = linkOptions?.resolvedLinkURL(link.absoluteString),
280 let url = URL(string: resolved) {
281 attributed[range].link = url
282 }
283 }
284 return attributed
285 }
286
287 private func headingFont(_ level: Int) -> Font {
288 switch level {
289 case 1: .title2.bold()
290 case 2: .title3.bold()
291 case 3: .headline
292 default: .subheadline.bold()
293 }
294 }
295
296 /// One step below the heading's own size, so code in a heading tracks it.
297 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
298 switch level {
299 case 1: .title3
300 case 2: .headline
301 case 3: .subheadline
302 default: .footnote
303 }
304 }
305}
306
307/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
308///
309/// A multi-column table is laid out two columns to a screen, so long prose wraps
310/// inside its cell instead of running off the edge and wider tables scroll. A cell
311/// needs a width proposed to it, not a cap: with only a cap it reports the height of
312/// a single line and rows overlap.
313private struct MarkdownTableView: View {
314 let table: MarkdownView.Table
315 var linkOptions: OrgRenderOptions?
316
317 private static let spacing: CGFloat = 14
318
319 @State private var containerWidth: CGFloat = 0
320
321 private var cellWidth: CGFloat? {
322 guard containerWidth > 0, table.columnCount > 1 else { return nil }
323 return (containerWidth - Self.spacing) / 2
324 }
325
326 var body: some View {
327 ScrollView(.horizontal, showsIndicators: false) {
328 Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) {
329 GridRow {
330 ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in
331 cell(text, column).bold()
332 }
333 }
334 Divider().gridCellColumns(table.columnCount)
335 ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
336 GridRow {
337 ForEach(Array(row.enumerated()), id: \.offset) { column, text in
338 cell(text, column)
339 }
340 }
341 }
342 }
343 .padding(.vertical, 2)
344 }
345 .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 }
346 }
347
348 private func cell(_ text: String, _ column: Int) -> some View {
349 let alignment = table.alignments[column]
350 return MarkdownView.inline(text, linkOptions: linkOptions)
351 .multilineTextAlignment(Self.textAlignment(alignment))
352 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
353 .fixedSize(horizontal: cellWidth == nil, vertical: true)
354 .gridColumnAlignment(Self.gridAlignment(alignment))
355 }
356
357 private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment {
358 switch alignment {
359 case .leading: .leading
360 case .center: .center
361 case .trailing: .trailing
362 }
363 }
364
365 private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment {
366 switch alignment {
367 case .leading: .leading
368 case .center: .center
369 case .trailing: .trailing
370 }
371 }
372
373 private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment {
374 switch alignment {
375 case .leading: .leading
376 case .center: .center
377 case .trailing: .trailing
378 }
379 }
380}