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