gitbay/Views/Repos/MarkdownView.swift

v1.0.4
gitbay-ios/gitbay/Views/Repos/MarkdownView.swift history · blame · raw

354 lines · 13762 bytes

  1import SwiftUI
  2
  3/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
  4/// tables, 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    enum Block: Equatable {
 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 table(Table)
 27        case rule
 28        case paragraph(String)
 29    }
 30
 31    /// A GitHub-flavored table: a header row, a body, and one alignment per
 32    /// column taken from the `|:---:|` separator row.
 33    struct Table: Equatable {
 34        var header: [String]
 35        var rows: [[String]]
 36        var alignments: [Alignment]
 37
 38        enum Alignment: Equatable { case leading, center, trailing }
 39
 40        var columnCount: Int { header.count }
 41    }
 42
 43    var blocks: [Block] {
 44        var blocks: [Block] = []
 45        var lines = markdown.components(separatedBy: "\n")[...]
 46
 47        while let line = lines.first {
 48            let trimmed = line.trimmingCharacters(in: .whitespaces)
 49
 50            if trimmed.hasPrefix("```") {
 51                lines = lines.dropFirst()
 52                var code: [String] = []
 53                while let next = lines.first,
 54                      !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
 55                    code.append(next)
 56                    lines = lines.dropFirst()
 57                }
 58                lines = lines.dropFirst() // closing fence
 59                blocks.append(.code(code.joined(separator: "\n")))
 60                continue
 61            }
 62            if trimmed.hasPrefix("#") {
 63                let level = trimmed.prefix(while: { $0 == "#" }).count
 64                let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
 65                blocks.append(.heading(level: min(level, 4), text: text))
 66                lines = lines.dropFirst()
 67                continue
 68            }
 69            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
 70                blocks.append(.rule)
 71                lines = lines.dropFirst()
 72                continue
 73            }
 74            if trimmed.hasPrefix(">") {
 75                var quote: [String] = []
 76                while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
 77                    quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
 78                    lines = lines.dropFirst()
 79                }
 80                blocks.append(.quote(quote.joined(separator: " ")))
 81                continue
 82            }
 83            if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
 84                var items: [String] = []
 85                while let next = lines.first?.trimmingCharacters(in: .whitespaces),
 86                      next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
 87                    items.append(String(next.dropFirst(2)))
 88                    lines = lines.dropFirst()
 89                }
 90                blocks.append(.bullet(items))
 91                continue
 92            }
 93            if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
 94                var items: [String] = []
 95                while let next = lines.first?.trimmingCharacters(in: .whitespaces),
 96                      let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
 97                    items.append(String(next[range.upperBound...]))
 98                    lines = lines.dropFirst()
 99                }
100                blocks.append(.ordered(items))
101                continue
102            }
103            if let table = Self.tableStart(lines) {
104                lines = lines.dropFirst(2) // header and separator
105                var rows: [[String]] = []
106                while let next = lines.first, next.contains("|") {
107                    var cells = Self.cells(next)
108                    // Ragged rows are padded, not dropped, so a short row leaves
109                    // blanks rather than shifting the grid.
110                    cells += Array(repeating: "", count: max(0, table.header.count - cells.count))
111                    rows.append(Array(cells.prefix(table.header.count)))
112                    lines = lines.dropFirst()
113                }
114                blocks.append(.table(Table(
115                    header: table.header, rows: rows, alignments: table.alignments
116                )))
117                continue
118            }
119            if trimmed.isEmpty {
120                lines = lines.dropFirst()
121                continue
122            }
123            // Paragraph: consume until a blank line or another block start.
124            var paragraph: [String] = []
125            while let next = lines.first {
126                let t = next.trimmingCharacters(in: .whitespaces)
127                if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
128                    || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
129                    break
130                }
131                paragraph.append(t)
132                lines = lines.dropFirst()
133            }
134            blocks.append(.paragraph(paragraph.joined(separator: " ")))
135        }
136        return blocks
137    }
138
139    // MARK: - Tables
140
141    /// A table begins where a row of cells is followed by a separator row with the
142    /// same number of cells. Anything else containing a pipe stays a paragraph.
143    private static func tableStart(
144        _ lines: ArraySlice<String>
145    ) -> (header: [String], alignments: [Table.Alignment])? {
146        guard let head = lines.first, head.contains("|"),
147              let separator = lines.dropFirst().first
148        else { return nil }
149        let header = cells(head)
150        let rules = cells(separator)
151        guard header.count == rules.count, !header.isEmpty,
152              rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil })
153        else { return nil }
154        return (header, rules.map(alignment))
155    }
156
157    private static func alignment(of rule: String) -> Table.Alignment {
158        switch (rule.hasPrefix(":"), rule.hasSuffix(":")) {
159        case (true, true): .center
160        case (false, true): .trailing
161        default: .leading
162        }
163    }
164
165    /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and
166    /// a `\|` stays escaped so the inline parser turns it into a literal pipe.
167    private static func cells(_ line: String) -> [String] {
168        var row = Substring(line.trimmingCharacters(in: .whitespaces))
169        if row.hasPrefix("|") { row = row.dropFirst() }
170        if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() }
171
172        var cells: [String] = []
173        var current = ""
174        var escaped = false
175        for character in row {
176            if escaped {
177                current.append(character)
178                escaped = false
179            } else if character == "\\" {
180                current.append(character)
181                escaped = true
182            } else if character == "|" {
183                cells.append(current)
184                current = ""
185            } else {
186                current.append(character)
187            }
188        }
189        cells.append(current)
190        return cells.map { $0.trimmingCharacters(in: .whitespaces) }
191    }
192
193    // MARK: - Rendering
194
195    @ViewBuilder
196    private func blockView(_ block: Block) -> some View {
197        switch block {
198        case .heading(let level, let text):
199            Self.inline(text, codeStyle: headingCodeStyle(level))
200                .font(headingFont(level))
201                .padding(.top, level <= 2 ? 8 : 4)
202        case .code(let code):
203            ScrollView(.horizontal) {
204                Text(code)
205                    .font(.gbMono(.caption))
206                    .padding(10)
207            }
208            .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
209        case .quote(let text):
210            HStack(spacing: 10) {
211                RoundedRectangle(cornerRadius: 2)
212                    .fill(.tertiary)
213                    .frame(width: 3)
214                Self.inline(text).foregroundStyle(.secondary)
215            }
216            .fixedSize(horizontal: false, vertical: true)
217        case .bullet(let items):
218            VStack(alignment: .leading, spacing: 4) {
219                ForEach(Array(items.enumerated()), id: \.offset) { _, item in
220                    HStack(alignment: .firstTextBaseline, spacing: 8) {
221                        Text("•")
222                        Self.inline(item)
223                    }
224                }
225            }
226        case .ordered(let items):
227            VStack(alignment: .leading, spacing: 4) {
228                ForEach(Array(items.enumerated()), id: \.offset) { index, item in
229                    HStack(alignment: .firstTextBaseline, spacing: 8) {
230                        Text("\(index + 1).").monospacedDigit()
231                        Self.inline(item)
232                    }
233                }
234            }
235        case .table(let table):
236            MarkdownTableView(table: table)
237        case .rule:
238            Divider()
239        case .paragraph(let text):
240            Self.inline(text)
241        }
242    }
243
244    /// Inline code is set explicitly rather than left to the substitution
245    /// AttributedString makes for a custom face, which comes out optically larger
246    /// than the surrounding sans. `codeStyle` is a step below the block's own style,
247    /// the ratio the web uses (`--fs-1` against `--fs-3`).
248    static func inline(_ text: String, codeStyle: Font.TextStyle = .subheadline) -> Text {
249        guard var attributed = try? AttributedString(
250            markdown: text,
251            options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
252        ) else {
253            return Text(text)
254        }
255        for range in attributed.runs.map(\.range)
256        where attributed[range].inlinePresentationIntent?.contains(.code) == true {
257            attributed[range].font = .gbMono(codeStyle)
258        }
259        return Text(attributed)
260    }
261
262    private func headingFont(_ level: Int) -> Font {
263        switch level {
264        case 1: .title2.bold()
265        case 2: .title3.bold()
266        case 3: .headline
267        default: .subheadline.bold()
268        }
269    }
270
271    /// One step below the heading's own size, so code in a heading tracks it.
272    private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
273        switch level {
274        case 1: .title3
275        case 2: .headline
276        case 3: .subheadline
277        default: .footnote
278        }
279    }
280}
281
282/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
283///
284/// A multi-column table is laid out two columns to a screen, so long prose wraps
285/// inside its cell instead of running off the edge and wider tables scroll. A cell
286/// needs a width proposed to it, not a cap: with only a cap it reports the height of
287/// a single line and rows overlap.
288private struct MarkdownTableView: View {
289    let table: MarkdownView.Table
290
291    private static let spacing: CGFloat = 14
292
293    @State private var containerWidth: CGFloat = 0
294
295    private var cellWidth: CGFloat? {
296        guard containerWidth > 0, table.columnCount > 1 else { return nil }
297        return (containerWidth - Self.spacing) / 2
298    }
299
300    var body: some View {
301        ScrollView(.horizontal, showsIndicators: false) {
302            Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) {
303                GridRow {
304                    ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in
305                        cell(text, column).bold()
306                    }
307                }
308                Divider().gridCellColumns(table.columnCount)
309                ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
310                    GridRow {
311                        ForEach(Array(row.enumerated()), id: \.offset) { column, text in
312                            cell(text, column)
313                        }
314                    }
315                }
316            }
317            .padding(.vertical, 2)
318        }
319        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 }
320    }
321
322    private func cell(_ text: String, _ column: Int) -> some View {
323        let alignment = table.alignments[column]
324        return MarkdownView.inline(text)
325            .multilineTextAlignment(Self.textAlignment(alignment))
326            .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
327            .fixedSize(horizontal: cellWidth == nil, vertical: true)
328            .gridColumnAlignment(Self.gridAlignment(alignment))
329    }
330
331    private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment {
332        switch alignment {
333        case .leading: .leading
334        case .center: .center
335        case .trailing: .trailing
336        }
337    }
338
339    private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment {
340        switch alignment {
341        case .leading: .leading
342        case .center: .center
343        case .trailing: .trailing
344        }
345    }
346
347    private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment {
348        switch alignment {
349        case .leading: .leading
350        case .center: .center
351        case .trailing: .trailing
352        }
353    }
354}