gitbay/Views/Repos/MarkdownView.swift

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

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