gitbay/Views/Repos/ReadmeView.swift
216 lines · 8427 bytes
1import Foundation
2import SwiftUI
3
4/// README rendering follows the file's format. Org is parsed as Org;
5/// everything else keeps the established Markdown renderer.
6struct ReadmeView: View {
7 let name: String
8 let content: String
9
10 /// Format follows the file name, never a guess at the content.
11 var isOrg: Bool { name.lowercased().hasSuffix(".org") }
12
13 var body: some View {
14 if isOrg {
15 OrgDocumentView(document: OrgDocument.parse(content))
16 } else {
17 MarkdownView(markdown: content)
18 }
19 }
20}
21
22nonisolated struct OrgDocument: Sendable, Hashable {
23 let blocks: [OrgBlock]
24
25 static func parse(_ source: String) -> OrgDocument {
26 var result: [OrgBlock] = []
27 var lines = source.components(separatedBy: "\n")[...]
28
29 while let raw = lines.first {
30 let line = raw.trimmingCharacters(in: .whitespaces)
31 if line.isEmpty {
32 lines = lines.dropFirst()
33 continue
34 }
35 let lower = line.lowercased()
36 if lower.hasPrefix("#+title:") {
37 result.append(.heading(level: 1, text: String(line.dropFirst(8)).trimmingCharacters(in: .whitespaces)))
38 lines = lines.dropFirst()
39 continue
40 }
41 if lower.hasPrefix("#+begin_src") || lower.hasPrefix("#+begin_example") {
42 let language = lower.hasPrefix("#+begin_src")
43 ? String(line.dropFirst(11)).trimmingCharacters(in: .whitespaces)
44 : ""
45 lines = lines.dropFirst()
46 var body: [String] = []
47 while let next = lines.first,
48 !next.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("#+end_") {
49 body.append(next)
50 lines = lines.dropFirst()
51 }
52 if !lines.isEmpty { lines = lines.dropFirst() }
53 result.append(.code(language: language, text: body.joined(separator: "\n")))
54 continue
55 }
56 if lower == "#+begin_quote" {
57 lines = lines.dropFirst()
58 var body: [String] = []
59 while let next = lines.first,
60 next.trimmingCharacters(in: .whitespaces).lowercased() != "#+end_quote" {
61 body.append(next.trimmingCharacters(in: .whitespaces))
62 lines = lines.dropFirst()
63 }
64 if !lines.isEmpty { lines = lines.dropFirst() }
65 result.append(.quote(body.joined(separator: " ")))
66 continue
67 }
68 if line.first == "*" {
69 let stars = line.prefix(while: { $0 == "*" }).count
70 if line.dropFirst(stars).first == " " {
71 result.append(.heading(
72 level: min(stars, 4),
73 text: String(line.dropFirst(stars)).trimmingCharacters(in: .whitespaces)
74 ))
75 lines = lines.dropFirst()
76 continue
77 }
78 }
79 if line.hasPrefix("- ") || line.hasPrefix("+ ") {
80 var items: [String] = []
81 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
82 next.hasPrefix("- ") || next.hasPrefix("+ ") {
83 items.append(String(next.dropFirst(2)))
84 lines = lines.dropFirst()
85 }
86 result.append(.bullet(items))
87 continue
88 }
89 if line.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil {
90 var items: [String] = []
91 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
92 let range = next.range(of: #"^\d+[.)] "#, options: .regularExpression) {
93 items.append(String(next[range.upperBound...]))
94 lines = lines.dropFirst()
95 }
96 result.append(.ordered(items))
97 continue
98 }
99 if lower.hasPrefix("#+") {
100 lines = lines.dropFirst() // document metadata, not prose
101 continue
102 }
103
104 var paragraph: [String] = []
105 while let next = lines.first {
106 let trimmed = next.trimmingCharacters(in: .whitespaces)
107 let nextLower = trimmed.lowercased()
108 if trimmed.isEmpty || trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ")
109 || trimmed.hasPrefix("+ ") || nextLower.hasPrefix("#+") {
110 break
111 }
112 paragraph.append(trimmed)
113 lines = lines.dropFirst()
114 }
115 result.append(.paragraph(paragraph.joined(separator: " ")))
116 }
117 return OrgDocument(blocks: result)
118 }
119}
120
121nonisolated enum OrgBlock: Sendable, Hashable {
122 case heading(level: Int, text: String)
123 case code(language: String, text: String)
124 case quote(String)
125 case bullet([String])
126 case ordered([String])
127 case paragraph(String)
128}
129
130private struct OrgDocumentView: View {
131 let document: OrgDocument
132
133 var body: some View {
134 VStack(alignment: .leading, spacing: 12) {
135 ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in
136 blockView(block)
137 }
138 }
139 }
140
141 @ViewBuilder
142 private func blockView(_ block: OrgBlock) -> some View {
143 switch block {
144 case .heading(let level, let text):
145 inline(text)
146 .font(headingFont(level))
147 .padding(.top, level <= 2 ? 8 : 4)
148 case .code(let language, let text):
149 VStack(alignment: .leading, spacing: 4) {
150 if !language.isEmpty {
151 Text(language)
152 .font(.gbMono(.caption2))
153 .foregroundStyle(.secondary)
154 }
155 ScrollView(.horizontal) {
156 Text(text)
157 .font(.gbMono(.caption))
158 .padding(10)
159 }
160 }
161 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
162 case .quote(let text):
163 HStack(spacing: 10) {
164 RoundedRectangle(cornerRadius: 2).fill(.tertiary).frame(width: 3)
165 inline(text).foregroundStyle(.secondary)
166 }
167 .fixedSize(horizontal: false, vertical: true)
168 case .bullet(let items):
169 list(items, ordered: false)
170 case .ordered(let items):
171 list(items, ordered: true)
172 case .paragraph(let text):
173 inline(text)
174 }
175 }
176
177 private func list(_ items: [String], ordered: Bool) -> some View {
178 VStack(alignment: .leading, spacing: 4) {
179 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
180 HStack(alignment: .firstTextBaseline, spacing: 8) {
181 Text(ordered ? "\(index + 1)." : "•")
182 inline(item)
183 }
184 }
185 }
186 }
187
188 private func inline(_ source: String) -> Text {
189 var markdown = source
190 markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, template: "[$2]($1)")
191 markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\]"#, template: "<$1>")
192 markdown = replacing(markdown, pattern: #"=([^=]+)="#, template: "`$1`")
193 if let attributed = try? AttributedString(
194 markdown: markdown,
195 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
196 ) {
197 return Text(attributed)
198 }
199 return Text(source)
200 }
201
202 private func replacing(_ source: String, pattern: String, template: String) -> String {
203 guard let expression = try? NSRegularExpression(pattern: pattern) else { return source }
204 let range = NSRange(source.startIndex..., in: source)
205 return expression.stringByReplacingMatches(in: source, range: range, withTemplate: template)
206 }
207
208 private func headingFont(_ level: Int) -> Font {
209 switch level {
210 case 1: .gbSans(.title2).bold()
211 case 2: .gbSans(.title3).bold()
212 case 3: .gbSans(.headline)
213 default: .gbSans(.subheadline).bold()
214 }
215 }
216}