Commit 19bbcd6de2
Verified · cmc
Layout: unified · split
gitbay/Views/Repos/MarkdownView.swift +191 −14
| @@ -1,7 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | /// Block-level markdown for READMEs: headings, fenced code, lists, quotes, | 3 | /// Block-level markdown for READMEs: headings, fenced code, lists, quotes, |
| 4 | /// paragraphs. Inline styling comes from AttributedString's own markdown | 4 | /// tables, paragraphs. Inline styling comes from AttributedString's own markdown |
| 5 | /// parsing; blocks are split here because it only does inline. | 5 | /// parsing; blocks are split here because it only does inline. |
| 6 | struct MarkdownView: View { | 6 | struct MarkdownView: View { |
| 7 | 7 | ||
| @@ -17,17 +17,30 @@ struct MarkdownView: View { | |||
| 17 | 17 | ||
| 18 | // MARK: - Block model | 18 | // MARK: - Block model |
| 19 | 19 | ||
| 20 | private enum Block { | 20 | enum Block: Equatable { |
| 21 | case heading(level: Int, text: String) | 21 | case heading(level: Int, text: String) |
| 22 | case code(String) | 22 | case code(String) |
| 23 | case quote(String) | 23 | case quote(String) |
| 24 | case bullet([String]) | 24 | case bullet([String]) |
| 25 | case ordered([String]) | 25 | case ordered([String]) |
| 26 | case table(Table) | ||
| 26 | case rule | 27 | case rule |
| 27 | case paragraph(String) | 28 | case paragraph(String) |
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | private var blocks: [Block] { | 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] { | ||
| 31 | var blocks: [Block] = [] | 44 | var blocks: [Block] = [] |
| 32 | var lines = markdown.components(separatedBy: "\n")[...] | 45 | var lines = markdown.components(separatedBy: "\n")[...] |
| 33 | 46 | ||
| @@ -87,6 +100,22 @@ struct MarkdownView: View { | |||
| 87 | blocks.append(.ordered(items)) | 100 | blocks.append(.ordered(items)) |
| 88 | continue | 101 | continue |
| 89 | } | 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 | } | ||
| 90 | if trimmed.isEmpty { | 119 | if trimmed.isEmpty { |
| 91 | lines = lines.dropFirst() | 120 | lines = lines.dropFirst() |
| 92 | continue | 121 | continue |
| @@ -96,7 +125,7 @@ struct MarkdownView: View { | |||
| 96 | while let next = lines.first { | 125 | while let next = lines.first { |
| 97 | let t = next.trimmingCharacters(in: .whitespaces) | 126 | let t = next.trimmingCharacters(in: .whitespaces) |
| 98 | if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">") | 127 | if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">") |
| 99 | || t.hasPrefix("- ") || t.hasPrefix("* ") { | 128 | || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil { |
| 100 | break | 129 | break |
| 101 | } | 130 | } |
| 102 | paragraph.append(t) | 131 | paragraph.append(t) |
| @@ -107,13 +136,67 @@ struct MarkdownView: View { | |||
| 107 | return blocks | 136 | return blocks |
| 108 | } | 137 | } |
| 109 | 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 | |||
| 110 | // MARK: - Rendering | 193 | // MARK: - Rendering |
| 111 | 194 | ||
| 112 | @ViewBuilder | 195 | @ViewBuilder |
| 113 | private func blockView(_ block: Block) -> some View { | 196 | private func blockView(_ block: Block) -> some View { |
| 114 | switch block { | 197 | switch block { |
| 115 | case .heading(let level, let text): | 198 | case .heading(let level, let text): |
| 116 | inline(text) | 199 | Self.inline(text, codeStyle: headingCodeStyle(level)) |
| 117 | .font(headingFont(level)) | 200 | .font(headingFont(level)) |
| 118 | .padding(.top, level <= 2 ? 8 : 4) | 201 | .padding(.top, level <= 2 ? 8 : 4) |
| 119 | case .code(let code): | 202 | case .code(let code): |
| @@ -128,7 +211,7 @@ struct MarkdownView: View { | |||
| 128 | RoundedRectangle(cornerRadius: 2) | 211 | RoundedRectangle(cornerRadius: 2) |
| 129 | .fill(.tertiary) | 212 | .fill(.tertiary) |
| 130 | .frame(width: 3) | 213 | .frame(width: 3) |
| 131 | inline(text).foregroundStyle(.secondary) | 214 | Self.inline(text).foregroundStyle(.secondary) |
| 132 | } | 215 | } |
| 133 | .fixedSize(horizontal: false, vertical: true) | 216 | .fixedSize(horizontal: false, vertical: true) |
| 134 | case .bullet(let items): | 217 | case .bullet(let items): |
| @@ -136,7 +219,7 @@ struct MarkdownView: View { | |||
| 136 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in | 219 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 137 | HStack(alignment: .firstTextBaseline, spacing: 8) { | 220 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 138 | Text("•") | 221 | Text("•") |
| 139 | inline(item) | 222 | Self.inline(item) |
| 140 | } | 223 | } |
| 141 | } | 224 | } |
| 142 | } | 225 | } |
| @@ -145,25 +228,35 @@ struct MarkdownView: View { | |||
| 145 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | 228 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 146 | HStack(alignment: .firstTextBaseline, spacing: 8) { | 229 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 147 | Text("\(index + 1).").monospacedDigit() | 230 | Text("\(index + 1).").monospacedDigit() |
| 148 | inline(item) | 231 | Self.inline(item) |
| 149 | } | 232 | } |
| 150 | } | 233 | } |
| 151 | } | 234 | } |
| 235 | case .table(let table): | ||
| 236 | MarkdownTableView(table: table) | ||
| 152 | case .rule: | 237 | case .rule: |
| 153 | Divider() | 238 | Divider() |
| 154 | case .paragraph(let text): | 239 | case .paragraph(let text): |
| 155 | inline(text) | 240 | Self.inline(text) |
| 156 | } | 241 | } |
| 157 | } | 242 | } |
| 158 | 243 | ||
| 159 | private func inline(_ text: String) -> Text { | 244 | /// Inline code is set explicitly rather than left to the substitution |
| 160 | if let attributed = try? AttributedString( | 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( | ||
| 161 | markdown: text, | 250 | markdown: text, |
| 162 | options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) | 251 | options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) |
| 163 | ) { | 252 | ) else { |
| 164 | return Text(attributed) | 253 | return Text(text) |
| 165 | } | 254 | } |
| 166 | return Text(text) | 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) | ||
| 167 | } | 260 | } |
| 168 | 261 | ||
| 169 | private func headingFont(_ level: Int) -> Font { | 262 | private func headingFont(_ level: Int) -> Font { |
| @@ -174,4 +267,88 @@ struct MarkdownView: View { | |||
| 174 | default: .subheadline.bold() | 267 | default: .subheadline.bold() |
| 175 | } | 268 | } |
| 176 | } | 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. | ||
| 288 | private 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 | } | ||
| 177 | } | 354 | } |
gitbayTests/MarkdownTests.swift added +92
| @@ -0,0 +1,92 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import gitbay | ||
| 3 | |||
| 4 | @MainActor | ||
| 5 | struct MarkdownTableTests { | ||
| 6 | |||
| 7 | private func blocks(_ markdown: String) -> [MarkdownView.Block] { | ||
| 8 | MarkdownView(markdown: markdown).blocks | ||
| 9 | } | ||
| 10 | |||
| 11 | @Test func headerSeparatorAndBodyBecomeATable() { | ||
| 12 | let parsed = blocks(""" | ||
| 13 | | Add | Documented in | | ||
| 14 | |---|---| | ||
| 15 | | An RSS feed | [Collections](https://orgo.krz.sh/) | | ||
| 16 | """) | ||
| 17 | #expect(parsed == [.table(MarkdownView.Table( | ||
| 18 | header: ["Add", "Documented in"], | ||
| 19 | rows: [["An RSS feed", "[Collections](https://orgo.krz.sh/)"]], | ||
| 20 | alignments: [.leading, .leading] | ||
| 21 | ))]) | ||
| 22 | } | ||
| 23 | |||
| 24 | @Test func alignmentComesFromTheSeparatorRow() { | ||
| 25 | let parsed = blocks(""" | ||
| 26 | | l | c | r | | ||
| 27 | |:--|:-:|--:| | ||
| 28 | | a | b | c | | ||
| 29 | """) | ||
| 30 | guard case .table(let table) = parsed.first else { | ||
| 31 | Issue.record("expected a table, got \(parsed)") | ||
| 32 | return | ||
| 33 | } | ||
| 34 | #expect(table.alignments == [.leading, .center, .trailing]) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test func outerPipesAreOptionalAndEscapedPipesStayInTheCell() { | ||
| 38 | let parsed = blocks(""" | ||
| 39 | a | b | ||
| 40 | --- | --- | ||
| 41 | x \\| y | z | ||
| 42 | """) | ||
| 43 | guard case .table(let table) = parsed.first else { | ||
| 44 | Issue.record("expected a table, got \(parsed)") | ||
| 45 | return | ||
| 46 | } | ||
| 47 | #expect(table.header == ["a", "b"]) | ||
| 48 | #expect(table.rows == [["x \\| y", "z"]]) | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func raggedRowsArePaddedToTheHeaderWidth() { | ||
| 52 | let parsed = blocks(""" | ||
| 53 | | a | b | c | | ||
| 54 | |---|---|---| | ||
| 55 | | 1 | | ||
| 56 | | 1 | 2 | 3 | 4 | | ||
| 57 | """) | ||
| 58 | guard case .table(let table) = parsed.first else { | ||
| 59 | Issue.record("expected a table, got \(parsed)") | ||
| 60 | return | ||
| 61 | } | ||
| 62 | #expect(table.rows == [["1", "", ""], ["1", "2", "3"]]) | ||
| 63 | } | ||
| 64 | |||
| 65 | @Test func aTableEndsAtABlankLineAndTheTextAfterItIsAParagraph() { | ||
| 66 | let parsed = blocks(""" | ||
| 67 | | a | | ||
| 68 | |---| | ||
| 69 | | 1 | | ||
| 70 | |||
| 71 | After the table. | ||
| 72 | """) | ||
| 73 | #expect(parsed.count == 2) | ||
| 74 | #expect(parsed.last == .paragraph("After the table.")) | ||
| 75 | } | ||
| 76 | |||
| 77 | @Test func aTableInterruptsThePrecedingParagraph() { | ||
| 78 | let parsed = blocks(""" | ||
| 79 | Each of these has a page in the guide: | ||
| 80 | | a | | ||
| 81 | |---| | ||
| 82 | | 1 | | ||
| 83 | """) | ||
| 84 | #expect(parsed.first == .paragraph("Each of these has a page in the guide:")) | ||
| 85 | #expect(parsed.count == 2) | ||
| 86 | } | ||
| 87 | |||
| 88 | @Test func aPipeInProseIsNotATable() { | ||
| 89 | let parsed = blocks("Run `a | b` to pipe.\nIt is not a table.") | ||
| 90 | #expect(parsed == [.paragraph("Run `a | b` to pipe. It is not a table.")]) | ||
| 91 | } | ||
| 92 | } | ||