gitbay/Views/Repos/MarkdownView.swift
177 lines · 6597 bytes
1import SwiftUI
2
3/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
4/// 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 private enum Block {
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 rule
27 case paragraph(String)
28 }
29
30 private var blocks: [Block] {
31 var blocks: [Block] = []
32 var lines = markdown.components(separatedBy: "\n")[...]
33
34 while let line = lines.first {
35 let trimmed = line.trimmingCharacters(in: .whitespaces)
36
37 if trimmed.hasPrefix("```") {
38 lines = lines.dropFirst()
39 var code: [String] = []
40 while let next = lines.first,
41 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
42 code.append(next)
43 lines = lines.dropFirst()
44 }
45 lines = lines.dropFirst() // closing fence
46 blocks.append(.code(code.joined(separator: "\n")))
47 continue
48 }
49 if trimmed.hasPrefix("#") {
50 let level = trimmed.prefix(while: { $0 == "#" }).count
51 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
52 blocks.append(.heading(level: min(level, 4), text: text))
53 lines = lines.dropFirst()
54 continue
55 }
56 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
57 blocks.append(.rule)
58 lines = lines.dropFirst()
59 continue
60 }
61 if trimmed.hasPrefix(">") {
62 var quote: [String] = []
63 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
64 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
65 lines = lines.dropFirst()
66 }
67 blocks.append(.quote(quote.joined(separator: " ")))
68 continue
69 }
70 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
71 var items: [String] = []
72 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
73 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
74 items.append(String(next.dropFirst(2)))
75 lines = lines.dropFirst()
76 }
77 blocks.append(.bullet(items))
78 continue
79 }
80 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
81 var items: [String] = []
82 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
83 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
84 items.append(String(next[range.upperBound...]))
85 lines = lines.dropFirst()
86 }
87 blocks.append(.ordered(items))
88 continue
89 }
90 if trimmed.isEmpty {
91 lines = lines.dropFirst()
92 continue
93 }
94 // Paragraph: consume until a blank line or another block start.
95 var paragraph: [String] = []
96 while let next = lines.first {
97 let t = next.trimmingCharacters(in: .whitespaces)
98 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
99 || t.hasPrefix("- ") || t.hasPrefix("* ") {
100 break
101 }
102 paragraph.append(t)
103 lines = lines.dropFirst()
104 }
105 blocks.append(.paragraph(paragraph.joined(separator: " ")))
106 }
107 return blocks
108 }
109
110 // MARK: - Rendering
111
112 @ViewBuilder
113 private func blockView(_ block: Block) -> some View {
114 switch block {
115 case .heading(let level, let text):
116 inline(text)
117 .font(headingFont(level))
118 .padding(.top, level <= 2 ? 8 : 4)
119 case .code(let code):
120 ScrollView(.horizontal) {
121 Text(code)
122 .font(.gbMono(.caption))
123 .padding(10)
124 }
125 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
126 case .quote(let text):
127 HStack(spacing: 10) {
128 RoundedRectangle(cornerRadius: 2)
129 .fill(.tertiary)
130 .frame(width: 3)
131 inline(text).foregroundStyle(.secondary)
132 }
133 .fixedSize(horizontal: false, vertical: true)
134 case .bullet(let items):
135 VStack(alignment: .leading, spacing: 4) {
136 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
137 HStack(alignment: .firstTextBaseline, spacing: 8) {
138 Text("•")
139 inline(item)
140 }
141 }
142 }
143 case .ordered(let items):
144 VStack(alignment: .leading, spacing: 4) {
145 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
146 HStack(alignment: .firstTextBaseline, spacing: 8) {
147 Text("\(index + 1).").monospacedDigit()
148 inline(item)
149 }
150 }
151 }
152 case .rule:
153 Divider()
154 case .paragraph(let text):
155 inline(text)
156 }
157 }
158
159 private func inline(_ text: String) -> Text {
160 if let attributed = try? AttributedString(
161 markdown: text,
162 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
163 ) {
164 return Text(attributed)
165 }
166 return Text(text)
167 }
168
169 private func headingFont(_ level: Int) -> Font {
170 switch level {
171 case 1: .title2.bold()
172 case 2: .title3.bold()
173 case 3: .headline
174 default: .subheadline.bold()
175 }
176 }
177}