gitbay/Views/Repos/MarkdownView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Repos/MarkdownView.swift history · blame · raw

542 lines · 22826 bytes

23 symbols in this file
  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    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
 17
 18    var body: some View {
 19        VStack(alignment: .leading, spacing: 12) {
 20            ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
 21                blockView(block)
 22            }
 23        }
 24    }
 25
 26    // MARK: - Block model
 27
 28    enum Block: Equatable {
 29        case heading(level: Int, text: String)
 30        case code(String)
 31        case quote(String)
 32        case bullet([String])
 33        case ordered([String])
 34        case table(Table)
 35        case rule
 36        case paragraph(String)
 37        case math(MathExpression)
 38    }
 39
 40    /// A GitHub-flavored table: a header row, a body, and one alignment per
 41    /// column taken from the `|:---:|` separator row.
 42    struct Table: Equatable {
 43        var header: [String]
 44        var rows: [[String]]
 45        var alignments: [Alignment]
 46
 47        enum Alignment: Equatable { case leading, center, trailing }
 48
 49        var columnCount: Int { header.count }
 50    }
 51
 52    var blocks: [Block] {
 53        Self.budgeted(parsedBlocks)
 54    }
 55
 56    private var parsedBlocks: [Block] {
 57        var blocks: [Block] = []
 58        var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...]
 59
 60        while let line = lines.first {
 61            let trimmed = line.trimmingCharacters(in: .whitespaces)
 62
 63            if trimmed.hasPrefix("```") {
 64                lines = lines.dropFirst()
 65                var code: [String] = []
 66                while let next = lines.first,
 67                      !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
 68                    code.append(next)
 69                    lines = lines.dropFirst()
 70                }
 71                lines = lines.dropFirst() // closing fence
 72                blocks.append(.code(code.joined(separator: "\n")))
 73                continue
 74            }
 75            if let (math, count) = markdownMathBlock(lines) {
 76                blocks.append(.math(math))
 77                lines = lines.dropFirst(count)
 78                continue
 79            }
 80            if let (level, text) = Self.atxHeading(trimmed) {
 81                blocks.append(.heading(level: min(level, 4), text: text))
 82                lines = lines.dropFirst()
 83                continue
 84            }
 85            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
 86                blocks.append(.rule)
 87                lines = lines.dropFirst()
 88                continue
 89            }
 90            if trimmed.hasPrefix(">") {
 91                var quote: [String] = []
 92                while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
 93                    quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
 94                    lines = lines.dropFirst()
 95                }
 96                blocks.append(.quote(quote.joined(separator: " ")))
 97                continue
 98            }
 99            if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
100                var items: [String] = []
101                while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
102                    if next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
103                        items.append(String(next.dropFirst(2)))
104                    } else if Self.continuesItem(lines) {
105                        items[items.count - 1] += " " + next
106                    } else {
107                        break
108                    }
109                    lines = lines.dropFirst()
110                }
111                blocks.append(.bullet(items))
112                continue
113            }
114            if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
115                var items: [String] = []
116                while let next = lines.first?.trimmingCharacters(in: .whitespaces) {
117                    if let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
118                        items.append(String(next[range.upperBound...]))
119                    } else if Self.continuesItem(lines) {
120                        items[items.count - 1] += " " + next
121                    } else {
122                        break
123                    }
124                    lines = lines.dropFirst()
125                }
126                blocks.append(.ordered(items))
127                continue
128            }
129            if let table = Self.tableStart(lines) {
130                lines = lines.dropFirst(2) // header and separator
131                var rows: [[String]] = []
132                while let next = lines.first, next.contains("|") {
133                    var cells = Self.cells(next)
134                    // Ragged rows are padded, not dropped, so a short row leaves
135                    // blanks rather than shifting the grid.
136                    cells += Array(repeating: "", count: max(0, table.header.count - cells.count))
137                    rows.append(Array(cells.prefix(table.header.count)))
138                    lines = lines.dropFirst()
139                }
140                blocks.append(.table(Table(
141                    header: table.header, rows: rows, alignments: table.alignments
142                )))
143                continue
144            }
145            if trimmed.isEmpty {
146                lines = lines.dropFirst()
147                continue
148            }
149            // Paragraph: consume until a blank line or another block start.
150            var paragraph: [String] = []
151            while let next = lines.first {
152                let t = next.trimmingCharacters(in: .whitespaces)
153                if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">")
154                    || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil
155                    || markdownMathBlock(lines) != nil {
156                    break
157                }
158                paragraph.append(t)
159                lines = lines.dropFirst()
160            }
161            blocks.append(.paragraph(paragraph.joined(separator: " ")))
162        }
163        return blocks
164    }
165
166    /// The blocks with math past the document's budget left as source. The
167    /// web parses blocks before inlines, so display blocks are counted first.
168    static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] {
169        var budget = budget
170        let blocks = blocks.map { block -> Block in
171            guard case .math(let math) = block else { return block }
172            return budget.take(math.tex) ? block : .code(math.source)
173        }
174        return blocks.map { block -> Block in
175            switch block {
176            case .heading(let level, let text): return .heading(level: level, text: budget.limit(text))
177            case .quote(let text): return .quote(budget.limit(text))
178            case .bullet(let items): return .bullet(items.map { budget.limit($0) })
179            case .ordered(let items): return .ordered(items.map { budget.limit($0) })
180            case .paragraph(let text): return .paragraph(budget.limit(text))
181            case .table(var table):
182                table.header = table.header.map { budget.limit($0) }
183                table.rows = table.rows.map { $0.map { budget.limit($0) } }
184                return .table(table)
185            case .math, .code, .rule: return block
186            }
187        }
188    }
189
190    /// An ATX heading: one to six `#` followed by a space, a tab or the end
191    /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
192    private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
193        let level = line.prefix(while: { $0 == "#" }).count
194        guard (1...6).contains(level) else { return nil }
195        let rest = line.dropFirst(level)
196        guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
197        return (level, rest.trimmingCharacters(in: .whitespaces))
198    }
199
200    /// A line that wraps the list item above it: anything but a blank line or
201    /// the start of another block, indented or not, as in CommonMark.
202    private static func continuesItem(_ lines: ArraySlice<String>) -> Bool {
203        guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty
204        else { return false }
205        let startsBlock = atxHeading(line) != nil || line.hasPrefix("```") || line.hasPrefix(">")
206            || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ")
207            || line == "---" || line == "***" || line == "___"
208            || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil
209            || tableStart(lines) != nil
210        return !startsBlock
211    }
212
213    // MARK: - Tables
214
215    /// A table begins where a row of cells is followed by a separator row with the
216    /// same number of cells. Anything else containing a pipe stays a paragraph.
217    private static func tableStart(
218        _ lines: ArraySlice<String>
219    ) -> (header: [String], alignments: [Table.Alignment])? {
220        guard let head = lines.first, head.contains("|"),
221              let separator = lines.dropFirst().first
222        else { return nil }
223        let header = cells(head)
224        let rules = cells(separator)
225        guard header.count == rules.count, !header.isEmpty,
226              rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil })
227        else { return nil }
228        return (header, rules.map(alignment))
229    }
230
231    private static func alignment(of rule: String) -> Table.Alignment {
232        switch (rule.hasPrefix(":"), rule.hasSuffix(":")) {
233        case (true, true): .center
234        case (false, true): .trailing
235        default: .leading
236        }
237    }
238
239    /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and
240    /// a `\|` stays escaped so the inline parser turns it into a literal pipe.
241    private static func cells(_ line: String) -> [String] {
242        var row = Substring(line.trimmingCharacters(in: .whitespaces))
243        if row.hasPrefix("|") { row = row.dropFirst() }
244        if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() }
245
246        var cells: [String] = []
247        var current = ""
248        var escaped = false
249        for character in row {
250            if escaped {
251                current.append(character)
252                escaped = false
253            } else if character == "\\" {
254                current.append(character)
255                escaped = true
256            } else if character == "|" {
257                cells.append(current)
258                current = ""
259            } else {
260                current.append(character)
261            }
262        }
263        cells.append(current)
264        return cells.map { $0.trimmingCharacters(in: .whitespaces) }
265    }
266
267    // MARK: - Rendering
268
269    @ViewBuilder
270    private func blockView(_ block: Block) -> some View {
271        switch block {
272        case .heading(let level, let text):
273            Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
274                        referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level)))
275                .font(headingFont(level))
276                .padding(.top, level <= 2 ? 8 : 4)
277        case .code(let code):
278            ScrollView(.horizontal) {
279                Text(code)
280                    .font(.gbMono(.caption))
281                    .padding(10)
282            }
283            .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
284        case .quote(let text):
285            HStack(spacing: 10) {
286                RoundedRectangle(cornerRadius: 2)
287                    .fill(.tertiary)
288                    .frame(width: 3)
289                Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
290                    .foregroundStyle(.secondary)
291            }
292            .fixedSize(horizontal: false, vertical: true)
293        case .bullet(let items):
294            VStack(alignment: .leading, spacing: 4) {
295                ForEach(Array(items.enumerated()), id: \.offset) { _, item in
296                    HStack(alignment: .firstTextBaseline, spacing: 8) {
297                        Text("•")
298                        Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
299                                    mathSize: mathSize())
300                            .fixedSize(horizontal: false, vertical: true)
301                    }
302                }
303            }
304        case .ordered(let items):
305            VStack(alignment: .leading, spacing: 4) {
306                ForEach(Array(items.enumerated()), id: \.offset) { index, item in
307                    HStack(alignment: .firstTextBaseline, spacing: 8) {
308                        Text("\(index + 1).").monospacedDigit()
309                        Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
310                                    mathSize: mathSize())
311                            .fixedSize(horizontal: false, vertical: true)
312                    }
313                }
314            }
315        case .table(let table):
316            MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo,
317                              mathSize: mathSize())
318        case .rule:
319            Divider()
320        case .paragraph(let text):
321            Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
322        case .math(let math):
323            MathBlockView(math: math, pointSize: mathSize())
324        }
325    }
326
327    private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat {
328        UIFont.pointSize(style, dynamicTypeSize)
329    }
330
331    /// Inline code is set explicitly rather than left to the substitution
332    /// AttributedString makes for a custom face, which comes out optically larger
333    /// than the surrounding sans. `codeStyle` is a step below the block's own style,
334    /// the ratio the web uses (`--fs-1` against `--fs-3`).
335    ///
336    /// With `mathSize`, TeX is typeset at that size in place of its source.
337    /// Each expression stands in the markdown as a character the text does
338    /// not contain, so emphasis and links around it parse as they would
339    /// around a word.
340    static func inline(
341        _ text: String,
342        codeStyle: Font.TextStyle = .subheadline,
343        linkOptions: OrgRenderOptions? = nil,
344        referenceRepo: String? = nil,
345        mathSize: CGFloat? = nil
346    ) -> Text {
347        let pieces = mathSize == nil ? [] : splitMarkdownMath(text)
348        let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } }
349        guard let mathSize, !maths.isEmpty,
350              let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init)
351                .first(where: { !text.unicodeScalars.contains($0) })
352        else {
353            return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
354                                   referenceRepo: referenceRepo))
355        }
356        let marked = pieces.map { piece in
357            switch piece {
358            case .text(let text): text
359            case .math: String(mark)
360            }
361        }.joined()
362        let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions,
363                                  referenceRepo: referenceRepo)
364
365        // Display math sits on a line of its own, as the web's block-level
366        // <math> does. A mark compares by its first scalar, since a combining
367        // character after it joins the same Character.
368        var result = Text(verbatim: "")
369        var next = maths.makeIterator()
370        var run = rendered.startIndex
371        var afterDisplay = false
372        func prose(_ upper: AttributedString.Index, trimEnd: Bool) -> Text {
373            var piece = AttributedString(rendered[run..<upper])
374            if afterDisplay, let first = piece.characters.firstIndex(where: { !$0.isWhitespace }) {
375                piece = AttributedString(piece[first...])
376            }
377            if trimEnd, let last = piece.characters.lastIndex(where: { !$0.isWhitespace }) {
378                piece = AttributedString(piece[...last])
379            }
380            return Text(piece)
381        }
382        for index in rendered.characters.indices
383        where rendered.characters[index].unicodeScalars.first == mark {
384            let math = next.next()
385            let display = math?.display == true
386            let before = prose(index, trimEnd: display)
387            run = rendered.characters.index(after: index)
388            guard let math else {
389                result = Text("\(result)\(before)")
390                afterDisplay = false
391                continue
392            }
393            let drawn = MathRenderer.text(math, pointSize: mathSize)
394            if display {
395                let lead = index == rendered.startIndex ? "" : "\n"
396                let trail = run == rendered.endIndex ? "" : "\n"
397                result = Text("\(result)\(before)\(lead)\(drawn)\(trail)")
398            } else {
399                result = Text("\(result)\(before)\(drawn)")
400            }
401            afterDisplay = display
402        }
403        return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))")
404    }
405
406    static func attributed(
407        _ text: String,
408        codeStyle: Font.TextStyle = .subheadline,
409        linkOptions: OrgRenderOptions? = nil,
410        referenceRepo: String? = nil
411    ) -> AttributedString {
412        guard var attributed = try? AttributedString(
413            markdown: text,
414            options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
415        ) else {
416            return AttributedString(text)
417        }
418        for range in attributed.runs.map(\.range) {
419            if attributed[range].inlinePresentationIntent?.contains(.code) == true {
420                attributed[range].font = .gbMono(codeStyle)
421            }
422            // `[x](ARCHITECTURE.md)` parses to a scheme-less URL that `openURL` cannot open,
423            // so the text renders as a link and the tap does nothing. Resolve through the
424            // same options the org renderer uses, so both formats reach the same file page.
425            if let link = attributed[range].link,
426               let resolved = linkOptions?.resolvedLinkURL(link.absoluteString),
427               let url = URL(string: resolved) {
428                attributed[range].link = url
429            }
430        }
431        if let referenceRepo {
432            linkReferences(in: &attributed, repo: referenceRepo)
433        }
434        return attributed
435    }
436
437    private func headingFont(_ level: Int) -> Font {
438        switch level {
439        case 1: .title2.bold()
440        case 2: .title3.bold()
441        case 3: .headline
442        default: .subheadline.bold()
443        }
444    }
445
446    /// One step below the heading's own size, so code in a heading tracks it.
447    private func headingStyle(_ level: Int) -> UIFont.TextStyle {
448        switch level {
449        case 1: .title2
450        case 2: .title3
451        case 3: .headline
452        default: .subheadline
453        }
454    }
455
456    private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
457        switch level {
458        case 1: .title3
459        case 2: .headline
460        case 3: .subheadline
461        default: .footnote
462        }
463    }
464}
465
466/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
467///
468/// A multi-column table is laid out two columns to a screen, so long prose wraps
469/// inside its cell instead of running off the edge and wider tables scroll. A cell
470/// needs a width proposed to it, not a cap: with only a cap it reports the height of
471/// a single line and rows overlap.
472private struct MarkdownTableView: View {
473    let table: MarkdownView.Table
474    var linkOptions: OrgRenderOptions?
475    var referenceRepo: String?
476    var mathSize: CGFloat?
477
478    private static let spacing: CGFloat = 14
479
480    @State private var containerWidth: CGFloat = 0
481
482    private var cellWidth: CGFloat? {
483        guard containerWidth > 0, table.columnCount > 1 else { return nil }
484        return (containerWidth - Self.spacing) / 2
485    }
486
487    var body: some View {
488        ScrollView(.horizontal, showsIndicators: false) {
489            Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) {
490                GridRow {
491                    ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in
492                        cell(text, column).bold()
493                    }
494                }
495                Divider().gridCellColumns(table.columnCount)
496                ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
497                    GridRow {
498                        ForEach(Array(row.enumerated()), id: \.offset) { column, text in
499                            cell(text, column)
500                        }
501                    }
502                }
503            }
504            .padding(.vertical, 2)
505        }
506        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 }
507    }
508
509    private func cell(_ text: String, _ column: Int) -> some View {
510        let alignment = table.alignments[column]
511        return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo,
512                                   mathSize: mathSize)
513            .multilineTextAlignment(Self.textAlignment(alignment))
514            .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
515            .fixedSize(horizontal: cellWidth == nil, vertical: true)
516            .gridColumnAlignment(Self.gridAlignment(alignment))
517    }
518
519    private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment {
520        switch alignment {
521        case .leading: .leading
522        case .center: .center
523        case .trailing: .trailing
524        }
525    }
526
527    private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment {
528        switch alignment {
529        case .leading: .leading
530        case .center: .center
531        case .trailing: .trailing
532        }
533    }
534
535    private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment {
536        switch alignment {
537        case .leading: .leading
538        case .center: .center
539        case .trailing: .trailing
540        }
541    }
542}