import OrgSwift import SwiftUI /// Block-level markdown for READMEs: headings, fenced code, lists, quotes, /// tables, paragraphs. Inline styling comes from AttributedString's own markdown /// parsing; blocks are split here because it only does inline. struct MarkdownView: View { let markdown: String /// Repository context for resolving relative link targets. Nil leaves them as authored, /// which is right for a body with no file to be relative to. var linkOptions: OrgRenderOptions? /// The repository `#N` and `!N` refer to. Nil links no references. var referenceRepo: String? @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in blockView(block) } } } // MARK: - Block model enum Block: Equatable { case heading(level: Int, text: String) case code(String) case quote(String) case bullet([String]) case ordered([String]) case table(Table) case rule case paragraph(String) case math(MathExpression) } /// A GitHub-flavored table: a header row, a body, and one alignment per /// column taken from the `|:---:|` separator row. struct Table: Equatable { var header: [String] var rows: [[String]] var alignments: [Alignment] enum Alignment: Equatable { case leading, center, trailing } var columnCount: Int { header.count } } var blocks: [Block] { Self.budgeted(parsedBlocks) } private var parsedBlocks: [Block] { var blocks: [Block] = [] var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") { lines = lines.dropFirst() var code: [String] = [] while let next = lines.first, !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") { code.append(next) lines = lines.dropFirst() } lines = lines.dropFirst() // closing fence blocks.append(.code(code.joined(separator: "\n"))) continue } if let (math, count) = markdownMathBlock(lines) { blocks.append(.math(math)) lines = lines.dropFirst(count) continue } if let (level, text) = Self.atxHeading(trimmed) { blocks.append(.heading(level: min(level, 4), text: text)) lines = lines.dropFirst() continue } if trimmed == "---" || trimmed == "***" || trimmed == "___" { blocks.append(.rule) lines = lines.dropFirst() continue } if trimmed.hasPrefix(">") { var quote: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") { quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces)) lines = lines.dropFirst() } blocks.append(.quote(quote.joined(separator: " "))) continue } if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { var items: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces) { if next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") { items.append(String(next.dropFirst(2))) } else if Self.continuesItem(lines) { items[items.count - 1] += " " + next } else { break } lines = lines.dropFirst() } blocks.append(.bullet(items)) continue } if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil { var items: [String] = [] while let next = lines.first?.trimmingCharacters(in: .whitespaces) { if let range = next.range(of: #"^\d+\. "#, options: .regularExpression) { items.append(String(next[range.upperBound...])) } else if Self.continuesItem(lines) { items[items.count - 1] += " " + next } else { break } lines = lines.dropFirst() } blocks.append(.ordered(items)) continue } if let table = Self.tableStart(lines) { lines = lines.dropFirst(2) // header and separator var rows: [[String]] = [] while let next = lines.first, next.contains("|") { var cells = Self.cells(next) // Ragged rows are padded, not dropped, so a short row leaves // blanks rather than shifting the grid. cells += Array(repeating: "", count: max(0, table.header.count - cells.count)) rows.append(Array(cells.prefix(table.header.count))) lines = lines.dropFirst() } blocks.append(.table(Table( header: table.header, rows: rows, alignments: table.alignments ))) continue } if trimmed.isEmpty { lines = lines.dropFirst() continue } // Paragraph: consume until a blank line or another block start. var paragraph: [String] = [] while let next = lines.first { let t = next.trimmingCharacters(in: .whitespaces) if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">") || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil || markdownMathBlock(lines) != nil { break } paragraph.append(t) lines = lines.dropFirst() } blocks.append(.paragraph(paragraph.joined(separator: " "))) } return blocks } /// The blocks with math past the document's budget left as source. The /// web parses blocks before inlines, so display blocks are counted first. static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] { var budget = budget let blocks = blocks.map { block -> Block in guard case .math(let math) = block else { return block } return budget.take(math.tex) ? block : .code(math.source) } return blocks.map { block -> Block in switch block { case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) case .quote(let text): return .quote(budget.limit(text)) case .bullet(let items): return .bullet(items.map { budget.limit($0) }) case .ordered(let items): return .ordered(items.map { budget.limit($0) }) case .paragraph(let text): return .paragraph(budget.limit(text)) case .table(var table): table.header = table.header.map { budget.limit($0) } table.rows = table.rows.map { $0.map { budget.limit($0) } } return .table(table) case .math, .code, .rule: return block } } } /// An ATX heading: one to six `#` followed by a space, a tab or the end /// of the line, as in CommonMark. `#12 fixed this` is a paragraph. private static func atxHeading(_ line: String) -> (level: Int, text: String)? { let level = line.prefix(while: { $0 == "#" }).count guard (1...6).contains(level) else { return nil } let rest = line.dropFirst(level) guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil } return (level, rest.trimmingCharacters(in: .whitespaces)) } /// A line that wraps the list item above it: anything but a blank line or /// the start of another block, indented or not, as in CommonMark. private static func continuesItem(_ lines: ArraySlice) -> Bool { guard let line = lines.first?.trimmingCharacters(in: .whitespaces), !line.isEmpty else { return false } let startsBlock = atxHeading(line) != nil || line.hasPrefix("```") || line.hasPrefix(">") || line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") || line == "---" || line == "***" || line == "___" || line.range(of: #"^\d+\. "#, options: .regularExpression) != nil || tableStart(lines) != nil return !startsBlock } // MARK: - Tables /// A table begins where a row of cells is followed by a separator row with the /// same number of cells. Anything else containing a pipe stays a paragraph. private static func tableStart( _ lines: ArraySlice ) -> (header: [String], alignments: [Table.Alignment])? { guard let head = lines.first, head.contains("|"), let separator = lines.dropFirst().first else { return nil } let header = cells(head) let rules = cells(separator) guard header.count == rules.count, !header.isEmpty, rules.allSatisfy({ $0.range(of: #"^:?-+:?$"#, options: .regularExpression) != nil }) else { return nil } return (header, rules.map(alignment)) } private static func alignment(of rule: String) -> Table.Alignment { switch (rule.hasPrefix(":"), rule.hasSuffix(":")) { case (true, true): .center case (false, true): .trailing default: .leading } } /// Split a row on its unescaped pipes. The outer pipes are optional in GFM, and /// a `\|` stays escaped so the inline parser turns it into a literal pipe. private static func cells(_ line: String) -> [String] { var row = Substring(line.trimmingCharacters(in: .whitespaces)) if row.hasPrefix("|") { row = row.dropFirst() } if row.hasSuffix("|"), !row.hasSuffix("\\|") { row = row.dropLast() } var cells: [String] = [] var current = "" var escaped = false for character in row { if escaped { current.append(character) escaped = false } else if character == "\\" { current.append(character) escaped = true } else if character == "|" { cells.append(current) current = "" } else { current.append(character) } } cells.append(current) return cells.map { $0.trimmingCharacters(in: .whitespaces) } } // MARK: - Rendering @ViewBuilder private func blockView(_ block: Block) -> some View { switch block { case .heading(let level, let text): Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level))) .font(headingFont(level)) .padding(.top, level <= 2 ? 8 : 4) case .code(let code): ScrollView(.horizontal) { Text(code) .font(.gbMono(.caption)) .padding(10) } .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) case .quote(let text): HStack(spacing: 10) { RoundedRectangle(cornerRadius: 2) .fill(.tertiary) .frame(width: 3) Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) .foregroundStyle(.secondary) } .fixedSize(horizontal: false, vertical: true) case .bullet(let items): VStack(alignment: .leading, spacing: 4) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in HStack(alignment: .firstTextBaseline, spacing: 8) { Text("•") Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) .fixedSize(horizontal: false, vertical: true) } } } case .ordered(let items): VStack(alignment: .leading, spacing: 4) { ForEach(Array(items.enumerated()), id: \.offset) { index, item in HStack(alignment: .firstTextBaseline, spacing: 8) { Text("\(index + 1).").monospacedDigit() Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) .fixedSize(horizontal: false, vertical: true) } } } case .table(let table): MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) case .rule: Divider() case .paragraph(let text): Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) case .math(let math): MathBlockView(math: math, pointSize: mathSize()) } } private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat { UIFont.pointSize(style, dynamicTypeSize) } /// Inline code is set explicitly rather than left to the substitution /// AttributedString makes for a custom face, which comes out optically larger /// than the surrounding sans. `codeStyle` is a step below the block's own style, /// the ratio the web uses (`--fs-1` against `--fs-3`). /// /// With `mathSize`, TeX is typeset at that size in place of its source. /// Each expression stands in the markdown as a character the text does /// not contain, so emphasis and links around it parse as they would /// around a word. static func inline( _ text: String, codeStyle: Font.TextStyle = .subheadline, linkOptions: OrgRenderOptions? = nil, referenceRepo: String? = nil, mathSize: CGFloat? = nil ) -> Text { let pieces = mathSize == nil ? [] : splitMarkdownMath(text) let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } } guard let mathSize, !maths.isEmpty, let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init) .first(where: { !text.unicodeScalars.contains($0) }) else { return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, referenceRepo: referenceRepo)) } let marked = pieces.map { piece in switch piece { case .text(let text): text case .math: String(mark) } }.joined() let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, referenceRepo: referenceRepo) // Display math sits on a line of its own, as the web's block-level // does. A mark compares by its first scalar, since a combining // character after it joins the same Character. var result = Text(verbatim: "") var next = maths.makeIterator() var run = rendered.startIndex var afterDisplay = false func prose(_ upper: AttributedString.Index, trimEnd: Bool) -> Text { var piece = AttributedString(rendered[run.. AttributedString { guard var attributed = try? AttributedString( markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) else { return AttributedString(text) } for range in attributed.runs.map(\.range) { if attributed[range].inlinePresentationIntent?.contains(.code) == true { attributed[range].font = .gbMono(codeStyle) } // `[x](ARCHITECTURE.md)` parses to a scheme-less URL that `openURL` cannot open, // so the text renders as a link and the tap does nothing. Resolve through the // same options the org renderer uses, so both formats reach the same file page. if let link = attributed[range].link, let resolved = linkOptions?.resolvedLinkURL(link.absoluteString), let url = URL(string: resolved) { attributed[range].link = url } } if let referenceRepo { linkReferences(in: &attributed, repo: referenceRepo) } return attributed } private func headingFont(_ level: Int) -> Font { switch level { case 1: .title2.bold() case 2: .title3.bold() case 3: .headline default: .subheadline.bold() } } /// One step below the heading's own size, so code in a heading tracks it. private func headingStyle(_ level: Int) -> UIFont.TextStyle { switch level { case 1: .title2 case 2: .title3 case 3: .headline default: .subheadline } } private func headingCodeStyle(_ level: Int) -> Font.TextStyle { switch level { case 1: .title3 case 2: .headline case 3: .subheadline default: .footnote } } } /// A table, in a `Grid` that scrolls sideways when it is wider than the screen. /// /// A multi-column table is laid out two columns to a screen, so long prose wraps /// inside its cell instead of running off the edge and wider tables scroll. A cell /// needs a width proposed to it, not a cap: with only a cap it reports the height of /// a single line and rows overlap. private struct MarkdownTableView: View { let table: MarkdownView.Table var linkOptions: OrgRenderOptions? var referenceRepo: String? var mathSize: CGFloat? private static let spacing: CGFloat = 14 @State private var containerWidth: CGFloat = 0 private var cellWidth: CGFloat? { guard containerWidth > 0, table.columnCount > 1 else { return nil } return (containerWidth - Self.spacing) / 2 } var body: some View { ScrollView(.horizontal, showsIndicators: false) { Grid(alignment: .topLeading, horizontalSpacing: Self.spacing, verticalSpacing: 7) { GridRow { ForEach(Array(table.header.enumerated()), id: \.offset) { column, text in cell(text, column).bold() } } Divider().gridCellColumns(table.columnCount) ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in GridRow { ForEach(Array(row.enumerated()), id: \.offset) { column, text in cell(text, column) } } } } .padding(.vertical, 2) } .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 } } private func cell(_ text: String, _ column: Int) -> some View { let alignment = table.alignments[column] return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize) .multilineTextAlignment(Self.textAlignment(alignment)) .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) .fixedSize(horizontal: cellWidth == nil, vertical: true) .gridColumnAlignment(Self.gridAlignment(alignment)) } private static func gridAlignment(_ alignment: MarkdownView.Table.Alignment) -> HorizontalAlignment { switch alignment { case .leading: .leading case .center: .center case .trailing: .trailing } } private static func frameAlignment(_ alignment: MarkdownView.Table.Alignment) -> Alignment { switch alignment { case .leading: .leading case .center: .center case .trailing: .trailing } } private static func textAlignment(_ alignment: MarkdownView.Table.Alignment) -> TextAlignment { switch alignment { case .leading: .leading case .center: .center case .trailing: .trailing } } }