a native ios client for gitbay

client ios swift

https://gitbay.org

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}