gitbay/Views/Repos/MarkdownView.swift
542 lines · 22826 bytes
23 symbols in this file
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 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
17
18 var body: some View {
19 VStack(alignment: .leading, spacing: 12) {
20 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
21 blockView(block)
22 }
23 }
24 }
25
26 // MARK: - Block model
27
28 enum Block: Equatable {
29 case heading(level: Int, text: String)
30 case code(String)
31 case quote(String)
32 case bullet([String])
33 case ordered([String])
34 case table(Table)
35 case rule
36 case paragraph(String)
37 case math(MathExpression)
38 }
39
40 /// A GitHub-flavored table: a header row, a body, and one alignment per
41 /// column taken from the `|:---:|` separator row.
42 struct Table: Equatable {
43 var header: [String]
44 var rows: [[String]]
45 var alignments: [Alignment]
46
47 enum Alignment: Equatable { case leading, center, trailing }
48
49 var columnCount: Int { header.count }
50 }
51
52 var blocks: [Block] {
53 Self.budgeted(parsedBlocks)
54 }
55
56 private var parsedBlocks: [Block] {
57 var blocks: [Block] = []
58 var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...]
59
60 while let line = lines.first {
61 let trimmed = line.trimmingCharacters(in: .whitespaces)
62
63 if trimmed.hasPrefix("```") {
64 lines = lines.dropFirst()
65 var code: [String] = []
66 while let next = lines.first,
67 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
68 code.append(next)
69 lines = lines.dropFirst()
70 }
71 lines = lines.dropFirst() // closing fence
72 blocks.append(.code(code.joined(separator: "\n")))
73 continue
74 }
75 if let (math, count) = markdownMathBlock(lines) {
76 blocks.append(.math(math))
77 lines = lines.dropFirst(count)
78 continue
79 }
80 if let (level, text) = Self.atxHeading(trimmed) {
81 blocks.append(.heading(level: min(level, 4), text: text))
82 lines = lines.dropFirst()
83 continue
84 }
85 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
86 blocks.append(.rule)
87 lines = lines.dropFirst()
88 continue
89 }
90 if trimmed.hasPrefix(">") {
91 var quote: [String] = []
92 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
93 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
94 lines = lines.dropFirst()
95 }
96 blocks.append(.quote(quote.joined(separator: " ")))
97 continue
98 }
99 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
100 var items: [String] = []
101 while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
102 if next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
103 items.append(String(next.dropFirst(2)))
104 } else if Self.continuesItem(lines) {
105 items[items.count - 1] += " " + next
106 } else {
107 break
108 }
109 lines = lines.dropFirst()
110 }
111 blocks.append(.bullet(items))
112 continue
113 }
114 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
115 var items: [String] = []
116 while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
117 if let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
118 items.append(String(next[range.upperBound...]))
119 } else if Self.continuesItem(lines) {
120 items[items.count - 1] += " " + next
121 } else {
122 break
123 }
124 lines = lines.dropFirst()
125 }
126 blocks.append(.ordered(items))
127 continue
128 }
129 if let table = Self.tableStart(lines) {
130 lines = lines.dropFirst(2) // header and separator
131 var rows: [[String]] = []
132 while let next = lines.first, next.contains("|") {
133 var cells = Self.cells(next)
134 // Ragged rows are padded, not dropped, so a short row leaves
135 // blanks rather than shifting the grid.
136 cells += Array(repeating: "", count: max(0, table.header.count - cells.count))
137 rows.append(Array(cells.prefix(table.header.count)))
138 lines = lines.dropFirst()
139 }
140 blocks.append(.table(Table(
141 header: table.header, rows: rows, alignments: table.alignments
142 )))
143 continue
144 }
145 if trimmed.isEmpty {
146 lines = lines.dropFirst()
147 continue
148 }
149 // Paragraph: consume until a blank line or another block start.
150 var paragraph: [String] = []
151 while let next = lines.first {
152 let t = next.trimmingCharacters(in: .whitespaces)
153 if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">")
154 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil
155 || markdownMathBlock(lines) != nil {
156 break
157 }
158 paragraph.append(t)
159 lines = lines.dropFirst()
160 }
161 blocks.append(.paragraph(paragraph.joined(separator: " ")))
162 }
163 return blocks
164 }
165
166 /// The blocks with math past the document's budget left as source. The
167 /// web parses blocks before inlines, so display blocks are counted first.
168 static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] {
169 var budget = budget
170 let blocks = blocks.map { block -> Block in
171 guard case .math(let math) = block else { return block }
172 return budget.take(math.tex) ? block : .code(math.source)
173 }
174 return blocks.map { block -> Block in
175 switch block {
176 case .heading(let level, let text): return .heading(level: level, text: budget.limit(text))
177 case .quote(let text): return .quote(budget.limit(text))
178 case .bullet(let items): return .bullet(items.map { budget.limit($0) })
179 case .ordered(let items): return .ordered(items.map { budget.limit($0) })
180 case .paragraph(let text): return .paragraph(budget.limit(text))
181 case .table(var table):
182 table.header = table.header.map { budget.limit($0) }
183 table.rows = table.rows.map { $0.map { budget.limit($0) } }
184 return .table(table)
185 case .math, .code, .rule: return block
186 }
187 }
188 }
189
190 /// An ATX heading: one to six `#` followed by a space, a tab or the end
191 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
192 private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
193 let level = line.prefix(while: { $0 == "#" }).count
194 guard (1...6).contains(level) else { return nil }
195 let rest = line.dropFirst(level)
196 guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
197 return (level, rest.trimmingCharacters(in: .whitespaces))
198 }
199
200 /// A line that wraps the list item above it: anything but a blank line or
201 /// the start of another block, indented or not, as in CommonMark.
202 private static func continuesItem(_ lines: ArraySlice<String>) -> Bool {
203 guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty
204 else { return false }
205 let startsBlock = atxHeading(line) != nil || line.hasPrefix("```") || line.hasPrefix(">")
206 || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ")
207 || line == "---" || line == "***" || line == "___"
208 || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil
209 || tableStart(lines) != nil
210 return !startsBlock
211 }
212
213 // MARK: - Tables
214
215 /// A table begins where a row of cells is followed by a separator row with the
216 /// same number of cells. Anything else containing a pipe stays a paragraph.
217 private static func tableStart(
218 _ lines: ArraySlice<String>
219 ) -> (header: [String], alignments: [Table.Alignment])? {
220 guard let head = lines.first, head.contains("|"),
221 let separator = lines.dropFirst().first
222 else { return nil }
223 let header = cells(head)
224 let rules = cells(separator)
225 guard header.count == rules.count, !header.isEmpty,
226 rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil })
227 else { return nil }
228 return (header, rules.map(alignment))
229 }
230
231 private static func alignment(of rule: String) -> Table.Alignment {
232 switch (rule.hasPrefix(":"), rule.hasSuffix(":")) {
233 case (true, true): .center
234 case (false, true): .trailing
235 default: .leading
236 }
237 }
238
239 /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and
240 /// a `\|` stays escaped so the inline parser turns it into a literal pipe.
241 private static func cells(_ line: String) -> [String] {
242 var row = Substring(line.trimmingCharacters(in: .whitespaces))
243 if row.hasPrefix("|") { row = row.dropFirst() }
244 if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() }
245
246 var cells: [String] = []
247 var current = ""
248 var escaped = false
249 for character in row {
250 if escaped {
251 current.append(character)
252 escaped = false
253 } else if character == "\\" {
254 current.append(character)
255 escaped = true
256 } else if character == "|" {
257 cells.append(current)
258 current = ""
259 } else {
260 current.append(character)
261 }
262 }
263 cells.append(current)
264 return cells.map { $0.trimmingCharacters(in: .whitespaces) }
265 }
266
267 // MARK: - Rendering
268
269 @ViewBuilder
270 private func blockView(_ block: Block) -> some View {
271 switch block {
272 case .heading(let level, let text):
273 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
274 referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level)))
275 .font(headingFont(level))
276 .padding(.top, level <= 2 ? 8 : 4)
277 case .code(let code):
278 ScrollView(.horizontal) {
279 Text(code)
280 .font(.gbMono(.caption))
281 .padding(10)
282 }
283 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
284 case .quote(let text):
285 HStack(spacing: 10) {
286 RoundedRectangle(cornerRadius: 2)
287 .fill(.tertiary)
288 .frame(width: 3)
289 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
290 .foregroundStyle(.secondary)
291 }
292 .fixedSize(horizontal: false, vertical: true)
293 case .bullet(let items):
294 VStack(alignment: .leading, spacing: 4) {
295 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
296 HStack(alignment: .firstTextBaseline, spacing: 8) {
297 Text("•")
298 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
299 mathSize: mathSize())
300 .fixedSize(horizontal: false, vertical: true)
301 }
302 }
303 }
304 case .ordered(let items):
305 VStack(alignment: .leading, spacing: 4) {
306 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
307 HStack(alignment: .firstTextBaseline, spacing: 8) {
308 Text("\(index + 1).").monospacedDigit()
309 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
310 mathSize: mathSize())
311 .fixedSize(horizontal: false, vertical: true)
312 }
313 }
314 }
315 case .table(let table):
316 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo,
317 mathSize: mathSize())
318 case .rule:
319 Divider()
320 case .paragraph(let text):
321 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
322 case .math(let math):
323 MathBlockView(math: math, pointSize: mathSize())
324 }
325 }
326
327 private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat {
328 UIFont.pointSize(style, dynamicTypeSize)
329 }
330
331 /// Inline code is set explicitly rather than left to the substitution
332 /// AttributedString makes for a custom face, which comes out optically larger
333 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
334 /// the ratio the web uses (`--fs-1` against `--fs-3`).
335 ///
336 /// With `mathSize`, TeX is typeset at that size in place of its source.
337 /// Each expression stands in the markdown as a character the text does
338 /// not contain, so emphasis and links around it parse as they would
339 /// around a word.
340 static func inline(
341 _ text: String,
342 codeStyle: Font.TextStyle = .subheadline,
343 linkOptions: OrgRenderOptions? = nil,
344 referenceRepo: String? = nil,
345 mathSize: CGFloat? = nil
346 ) -> Text {
347 let pieces = mathSize == nil ? [] : splitMarkdownMath(text)
348 let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } }
349 guard let mathSize, !maths.isEmpty,
350 let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init)
351 .first(where: { !text.unicodeScalars.contains($0) })
352 else {
353 return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
354 referenceRepo: referenceRepo))
355 }
356 let marked = pieces.map { piece in
357 switch piece {
358 case .text(let text): text
359 case .math: String(mark)
360 }
361 }.joined()
362 let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions,
363 referenceRepo: referenceRepo)
364
365 // Display math sits on a line of its own, as the web's block-level
366 // <math> does. A mark compares by its first scalar, since a combining
367 // character after it joins the same Character.
368 var result = Text(verbatim: "")
369 var next = maths.makeIterator()
370 var run = rendered.startIndex
371 var afterDisplay = false
372 func prose(_ upper: AttributedString.Index, trimEnd: Bool) -> Text {
373 var piece = AttributedString(rendered[run..<upper])
374 if afterDisplay, let first = piece.characters.firstIndex(where: { !$0.isWhitespace }) {
375 piece = AttributedString(piece[first...])
376 }
377 if trimEnd, let last = piece.characters.lastIndex(where: { !$0.isWhitespace }) {
378 piece = AttributedString(piece[...last])
379 }
380 return Text(piece)
381 }
382 for index in rendered.characters.indices
383 where rendered.characters[index].unicodeScalars.first == mark {
384 let math = next.next()
385 let display = math?.display == true
386 let before = prose(index, trimEnd: display)
387 run = rendered.characters.index(after: index)
388 guard let math else {
389 result = Text("\(result)\(before)")
390 afterDisplay = false
391 continue
392 }
393 let drawn = MathRenderer.text(math, pointSize: mathSize)
394 if display {
395 let lead = index == rendered.startIndex ? "" : "\n"
396 let trail = run == rendered.endIndex ? "" : "\n"
397 result = Text("\(result)\(before)\(lead)\(drawn)\(trail)")
398 } else {
399 result = Text("\(result)\(before)\(drawn)")
400 }
401 afterDisplay = display
402 }
403 return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))")
404 }
405
406 static func attributed(
407 _ text: String,
408 codeStyle: Font.TextStyle = .subheadline,
409 linkOptions: OrgRenderOptions? = nil,
410 referenceRepo: String? = nil
411 ) -> AttributedString {
412 guard var attributed = try? AttributedString(
413 markdown: text,
414 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
415 ) else {
416 return AttributedString(text)
417 }
418 for range in attributed.runs.map(\.range) {
419 if attributed[range].inlinePresentationIntent?.contains(.code) == true {
420 attributed[range].font = .gbMono(codeStyle)
421 }
422 // `[x](ARCHITECTURE.md)` parses to a scheme-less URL that `openURL` cannot open,
423 // so the text renders as a link and the tap does nothing. Resolve through the
424 // same options the org renderer uses, so both formats reach the same file page.
425 if let link = attributed[range].link,
426 let resolved = linkOptions?.resolvedLinkURL(link.absoluteString),
427 let url = URL(string: resolved) {
428 attributed[range].link = url
429 }
430 }
431 if let referenceRepo {
432 linkReferences(in: &attributed, repo: referenceRepo)
433 }
434 return attributed
435 }
436
437 private func headingFont(_ level: Int) -> Font {
438 switch level {
439 case 1: .title2.bold()
440 case 2: .title3.bold()
441 case 3: .headline
442 default: .subheadline.bold()
443 }
444 }
445
446 /// One step below the heading's own size, so code in a heading tracks it.
447 private func headingStyle(_ level: Int) -> UIFont.TextStyle {
448 switch level {
449 case 1: .title2
450 case 2: .title3
451 case 3: .headline
452 default: .subheadline
453 }
454 }
455
456 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
457 switch level {
458 case 1: .title3
459 case 2: .headline
460 case 3: .subheadline
461 default: .footnote
462 }
463 }
464}
465
466/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
467///
468/// A multi-column table is laid out two columns to a screen, so long prose wraps
469/// inside its cell instead of running off the edge and wider tables scroll. A cell
470/// needs a width proposed to it, not a cap: with only a cap it reports the height of
471/// a single line and rows overlap.
472private struct MarkdownTableView: View {
473 let table: MarkdownView.Table
474 var linkOptions: OrgRenderOptions?
475 var referenceRepo: String?
476 var mathSize: CGFloat?
477
478 private static let spacing: CGFloat = 14
479
480 @State private var containerWidth: CGFloat = 0
481
482 private var cellWidth: CGFloat? {
483 guard containerWidth > 0, table.columnCount > 1 else { return nil }
484 return (containerWidth - Self.spacing) / 2
485 }
486
487 var body: some View {
488 ScrollView(.horizontal, showsIndicators: false) {
489 Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) {
490 GridRow {
491 ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in
492 cell(text, column).bold()
493 }
494 }
495 Divider().gridCellColumns(table.columnCount)
496 ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
497 GridRow {
498 ForEach(Array(row.enumerated()), id: \.offset) { column, text in
499 cell(text, column)
500 }
501 }
502 }
503 }
504 .padding(.vertical, 2)
505 }
506 .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 }
507 }
508
509 private func cell(_ text: String, _ column: Int) -> some View {
510 let alignment = table.alignments[column]
511 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo,
512 mathSize: mathSize)
513 .multilineTextAlignment(Self.textAlignment(alignment))
514 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
515 .fixedSize(horizontal: cellWidth == nil, vertical: true)
516 .gridColumnAlignment(Self.gridAlignment(alignment))
517 }
518
519 private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment {
520 switch alignment {
521 case .leading: .leading
522 case .center: .center
523 case .trailing: .trailing
524 }
525 }
526
527 private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment {
528 switch alignment {
529 case .leading: .leading
530 case .center: .center
531 case .trailing: .trailing
532 }
533 }
534
535 private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment {
536 switch alignment {
537 case .leading: .leading
538 case .center: .center
539 case .trailing: .trailing
540 }
541 }
542}