| @@ -1,3 +1,4 @@ |
| 1 | import OrgSwift |
| 1 | 2 | import SwiftUI |
| 2 | 3 | |
| 3 | 4 | /// Block-level markdown for READMEs: headings, fenced code, lists, quotes, |
| @@ -6,6 +7,9 @@ import SwiftUI |
| 6 | 7 | struct MarkdownView: View { |
| 7 | 8 | |
| 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? |
| 9 | 13 | |
| 10 | 14 | var body: some View { |
| 11 | 15 | VStack(alignment: .leading, spacing: 12) { |
| @@ -196,7 +200,7 @@ struct MarkdownView: View { |
| 196 | 200 | private func blockView(_ block: Block) -> some View { |
| 197 | 201 | switch block { |
| 198 | 202 | case .heading(let level, let text): |
| 199 | | Self.inline(text, codeStyle: headingCodeStyle(level)) |
| 203 | Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions) |
| 200 | 204 | .font(headingFont(level)) |
| 201 | 205 | .padding(.top, level <= 2 ? 8 : 4) |
| 202 | 206 | case .code(let code): |
| @@ -211,7 +215,7 @@ struct MarkdownView: View { |
| 211 | 215 | RoundedRectangle(cornerRadius: 2) |
| 212 | 216 | .fill(.tertiary) |
| 213 | 217 | .frame(width: 3) |
| 214 | | Self.inline(text).foregroundStyle(.secondary) |
| 218 | Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary) |
| 215 | 219 | } |
| 216 | 220 | .fixedSize(horizontal: false, vertical: true) |
| 217 | 221 | case .bullet(let items): |
| @@ -219,7 +223,7 @@ struct MarkdownView: View { |
| 219 | 223 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 220 | 224 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 221 | 225 | Text("•") |
| 222 | | Self.inline(item) |
| 226 | Self.inline(item, linkOptions: linkOptions) |
| 223 | 227 | } |
| 224 | 228 | } |
| 225 | 229 | } |
| @@ -228,16 +232,16 @@ struct MarkdownView: View { |
| 228 | 232 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 229 | 233 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 230 | 234 | Text("\(index + 1).").monospacedDigit() |
| 231 | | Self.inline(item) |
| 235 | Self.inline(item, linkOptions: linkOptions) |
| 232 | 236 | } |
| 233 | 237 | } |
| 234 | 238 | } |
| 235 | 239 | case .table(let table): |
| 236 | | MarkdownTableView(table: table) |
| 240 | MarkdownTableView(table: table, linkOptions: linkOptions) |
| 237 | 241 | case .rule: |
| 238 | 242 | Divider() |
| 239 | 243 | case .paragraph(let text): |
| 240 | | Self.inline(text) |
| 244 | Self.inline(text, linkOptions: linkOptions) |
| 241 | 245 | } |
| 242 | 246 | } |
| 243 | 247 | |
| @@ -245,18 +249,39 @@ struct MarkdownView: View { |
| 245 | 249 | /// AttributedString makes for a custom face, which comes out optically larger |
| 246 | 250 | /// than the surrounding sans. `codeStyle` is a step below the block's own style, |
| 247 | 251 | /// the ratio the web uses (`--fs-1` against `--fs-3`). |
| 248 | | static func inline(_ text: String, codeStyle: Font.TextStyle = .subheadline) -> Text { |
| 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 { |
| 249 | 265 | guard var attributed = try? AttributedString( |
| 250 | 266 | markdown: text, |
| 251 | 267 | options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) |
| 252 | 268 | ) else { |
| 253 | | return Text(text) |
| 269 | return AttributedString(text) |
| 254 | 270 | } |
| 255 | | for range in attributed.runs.map(\.range) |
| 256 | | where attributed[range].inlinePresentationIntent?.contains(.code) == true { |
| 257 | | attributed[range].font = .gbMono(codeStyle) |
| 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 | } |
| 258 | 283 | } |
| 259 | | return Text(attributed) |
| 284 | return attributed |
| 260 | 285 | } |
| 261 | 286 | |
| 262 | 287 | private func headingFont(_ level: Int) -> Font { |
| @@ -287,6 +312,7 @@ struct MarkdownView: View { |
| 287 | 312 | /// a single line and rows overlap. |
| 288 | 313 | private struct MarkdownTableView: View { |
| 289 | 314 | let table: MarkdownView.Table |
| 315 | var linkOptions: OrgRenderOptions? |
| 290 | 316 | |
| 291 | 317 | private static let spacing: CGFloat = 14 |
| 292 | 318 | |
| @@ -321,7 +347,7 @@ private struct MarkdownTableView: View { |
| 321 | 347 | |
| 322 | 348 | private func cell(_ text: String, _ column: Int) -> some View { |
| 323 | 349 | let alignment = table.alignments[column] |
| 324 | | return MarkdownView.inline(text) |
| 350 | return MarkdownView.inline(text, linkOptions: linkOptions) |
| 325 | 351 | .multilineTextAlignment(Self.textAlignment(alignment)) |
| 326 | 352 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) |
| 327 | 353 | .fixedSize(horizontal: cellWidth == nil, vertical: true) |