Commit 6481eac0e5

6481eac0e515a075aa3813acb559a9b8f0f72605

parent: 7be4d110b6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-01 00:33 UTC

Resolve relative links in a Markdown README

AttributedString's markdown parser sets .link to the raw target, so a relative
one became a scheme-less URL that openURL cannot open: styled as a link, inert
when tapped.

MarkdownView takes the same OrgRenderOptions the org path uses and rewrites
relative targets through OrgSwift's resolver, so both README formats reach the
same file page. Bodies with no file to be relative to (issues, MRs, releases)
pass no options and are unchanged.

org-swift 0.6.0. Closes #11.

Layout: unified · split

gitbay.xcodeproj/project.pbxproj +1 −1
@@ -591,7 +591,7 @@
591 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; 591 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
592 requirement = { 592 requirement = {
593 kind = upToNextMinorVersion; 593 kind = upToNextMinorVersion;
594 minimumVersion = 0.5.0; 594 minimumVersion = 0.6.0;
595 }; 595 };
596 }; 596 };
597 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { 597 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
@@ -15,8 +15,8 @@
15 "kind" : "remoteSourceControl", 15 "kind" : "remoteSourceControl",
16 "location" : "ssh://git@gitbay.org/krz/org-swift.git", 16 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
17 "state" : { 17 "state" : {
18 "revision" : "6c1f09c138d40fb92ad968ee4a0ef3d74b56c7da", 18 "revision" : "4e6e8a02b138886d93833ba4affba95689542c2f",
19 "version" : "0.5.0" 19 "version" : "0.6.0"
20 } 20 }
21 } 21 }
22 ], 22 ],
gitbay/Views/Repos/MarkdownView.swift +39 −13
@@ -1,3 +1,4 @@
1import OrgSwift
1import SwiftUI 2import 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
6struct MarkdownView: View { 7struct 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.
288private struct MarkdownTableView: View { 313private 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)
gitbay/Views/Repos/ReadmeView.swift +6 −3
@@ -23,14 +23,14 @@ struct ReadmeView: View {
23 // Qualified: `OrgView` here is org-mode, not the organization view. 23 // Qualified: `OrgView` here is org-mode, not the organization view.
24 OrgSwiftUI.OrgView( 24 OrgSwiftUI.OrgView(
25 Self.preferringPNGBadges(content, host: host), 25 Self.preferringPNGBadges(content, host: host),
26 options: orgOptions, 26 options: renderOptions,
27 styler: OrgSyntaxStyler(colorScheme: colorScheme), 27 styler: OrgSyntaxStyler(colorScheme: colorScheme),
28 // org's TODO/DONE are the same open/done distinction the forge already 28 // org's TODO/DONE are the same open/done distinction the forge already
29 // colors, so they reuse those tokens rather than a third treatment. 29 // colors, so they reuse those tokens rather than a third treatment.
30 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) 30 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
31 ) 31 )
32 } else { 32 } else {
33 MarkdownView(markdown: content) 33 MarkdownView(markdown: content, linkOptions: renderOptions)
34 } 34 }
35 } 35 }
36 36
@@ -55,7 +55,10 @@ struct ReadmeView: View {
55 55
56 /// gitbay serves raw bytes at `/{owner}/{repo}/raw/{ref}/…` and the file page at 56 /// gitbay serves raw bytes at `/{owner}/{repo}/raw/{ref}/…` and the file page at
57 /// `/blob/…`, so relative images resolve against `raw` and links against `blob`. 57 /// `/blob/…`, so relative images resolve against `raw` and links against `blob`.
58 private var orgOptions: OrgRenderOptions { 58 ///
59 /// Both formats resolve through the same options: a relative link in a Markdown README
60 /// points at the same file page an org one does.
61 private var renderOptions: OrgRenderOptions {
59 let parts = repoPath.split(separator: "/", maxSplits: 1).map(String.init) 62 let parts = repoPath.split(separator: "/", maxSplits: 1).map(String.init)
60 guard parts.count == 2, !host.isEmpty else { 63 guard parts.count == 2, !host.isEmpty else {
61 return OrgRenderOptions() 64 return OrgRenderOptions()
gitbayTests/MarkdownTests.swift +45
@@ -1,3 +1,5 @@
1import Foundation
2import OrgSwift
1import Testing 3import Testing
2@testable import gitbay 4@testable import gitbay
3 5
@@ -90,3 +92,46 @@ struct MarkdownTableTests {
90 #expect(parsed == [.paragraph("Run `a | b` to pipe. It is not a table.")]) 92 #expect(parsed == [.paragraph("Run `a | b` to pipe. It is not a table.")])
91 } 93 }
92} 94}
95
96@MainActor
97struct MarkdownLinkTests {
98
99 private let options = OrgRenderOptions(
100 host: "gitbay.org",
101 owner: "krz",
102 repositoryName: "org-swift",
103 ref: "HEAD",
104 readmePath: "README.md",
105 imagePathSegment: "raw",
106 linkPathSegment: "blob"
107 )
108
109 private func links(_ markdown: String, options: OrgRenderOptions?) -> [String] {
110 MarkdownView.attributed(markdown, linkOptions: options)
111 .runs.compactMap { $0.link?.absoluteString }
112 }
113
114 /// A relative target resolves to the repository's file page. Left alone it is a
115 /// scheme-less URL, which renders styled and does nothing when tapped.
116 @Test func relativeTargetsResolveAgainstTheRepository() {
117 #expect(links("See [ARCHITECTURE.md](ARCHITECTURE.md).", options: options)
118 == ["https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md"])
119 #expect(links("[gaps](./GAPS.md)", options: options)
120 == ["https://gitbay.org/krz/org-swift/blob/HEAD/GAPS.md"])
121 #expect(links("[corpus](../org-conformance)", options: options)
122 == ["https://gitbay.org/krz/org-swift/blob/HEAD/org-conformance"])
123 #expect(links("[docs](/docs/x.md)", options: options)
124 == ["https://gitbay.org/krz/org-swift/blob/HEAD/docs/x.md"])
125 }
126
127 @Test func absoluteTargetsAreUntouched() {
128 #expect(links("[home](https://example.com/a)", options: options)
129 == ["https://example.com/a"])
130 }
131
132 /// A body with no file to be relative to keeps its targets as authored.
133 @Test func withoutOptionsTargetsAreLeftAlone() {
134 #expect(links("See [ARCHITECTURE.md](ARCHITECTURE.md).", options: nil)
135 == ["ARCHITECTURE.md"])
136 }
137}