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