gitbay/Views/Repos/MarkdownView.swift

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

380 lines · 15070 bytes

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