a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/Repos/MarkdownView.swift

ui-smoke
gitbay-ios/gitbay/Views/Repos/MarkdownView.swift history · blame · raw

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}