Commit db5aaf444b

db5aaf444b67e85489fcc23cd10e4b94e2d93e81

parent: 7be4d110b6

Unsigned

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

Resolve relative links in a Markdown README (!44)

Closes #11.

`AttributedString(markdown:)` sets `.link` to the raw target, so
`[ARCHITECTURE.md](ARCHITECTURE.md)` became a scheme-less URL that `openURL`
cannot open — the text rendered blue and the tap did nothing.

`MarkdownView` now takes the same `OrgRenderOptions` `ReadmeView` already builds
for the org path and rewrites relative targets through OrgSwift's resolver
(krz/org-swift!8), so a relative link in a Markdown README reaches the same file
page an org one does. Bodies with no file to be relative to — issues, merge
requests, releases — pass no options and are unchanged.

Verified in the simulator on krz/org-swift: "See ARCHITECTURE.md." now opens
gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md. Full suite green, including
three new cases over `path`, `./path`, `../path`, `/abs/path`, and the
no-options passthrough.

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}