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 @@
591591 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
592592 requirement = {
593593 kind = upToNextMinorVersion;
594 minimumVersion = 0.5.0;
594 minimumVersion = 0.6.0;
595595 };
596596 };
597597 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
@@ -15,8 +15,8 @@
1515 "kind" : "remoteSourceControl",
1616 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
1717 "state" : {
18 "revision" : "6c1f09c138d40fb92ad968ee4a0ef3d74b56c7da",
19 "version" : "0.5.0"
18 "revision" : "4e6e8a02b138886d93833ba4affba95689542c2f",
19 "version" : "0.6.0"
2020 }
2121 }
2222 ],
gitbay/Views/Repos/MarkdownView.swift +39 −13
@@ -1,3 +1,4 @@
1import OrgSwift
12import SwiftUI
23
34/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
@@ -6,6 +7,9 @@ import SwiftUI
67struct MarkdownView: View {
78
89 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?
913
1014 var body: some View {
1115 VStack(alignment: .leading, spacing: 12) {
@@ -196,7 +200,7 @@ struct MarkdownView: View {
196200 private func blockView(_ block: Block) -> some View {
197201 switch block {
198202 case .heading(let level, let text):
199 Self.inline(text, codeStyle: headingCodeStyle(level))
203 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions)
200204 .font(headingFont(level))
201205 .padding(.top, level <= 2 ? 8 : 4)
202206 case .code(let code):
@@ -211,7 +215,7 @@ struct MarkdownView: View {
211215 RoundedRectangle(cornerRadius: 2)
212216 .fill(.tertiary)
213217 .frame(width: 3)
214 Self.inline(text).foregroundStyle(.secondary)
218 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary)
215219 }
216220 .fixedSize(horizontal: false, vertical: true)
217221 case .bullet(let items):
@@ -219,7 +223,7 @@ struct MarkdownView: View {
219223 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
220224 HStack(alignment: .firstTextBaseline, spacing: 8) {
221225 Text("•")
222 Self.inline(item)
226 Self.inline(item, linkOptions: linkOptions)
223227 }
224228 }
225229 }
@@ -228,16 +232,16 @@ struct MarkdownView: View {
228232 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
229233 HStack(alignment: .firstTextBaseline, spacing: 8) {
230234 Text("\(index + 1).").monospacedDigit()
231 Self.inline(item)
235 Self.inline(item, linkOptions: linkOptions)
232236 }
233237 }
234238 }
235239 case .table(let table):
236 MarkdownTableView(table: table)
240 MarkdownTableView(table: table, linkOptions: linkOptions)
237241 case .rule:
238242 Divider()
239243 case .paragraph(let text):
240 Self.inline(text)
244 Self.inline(text, linkOptions: linkOptions)
241245 }
242246 }
243247
@@ -245,18 +249,39 @@ struct MarkdownView: View {
245249 /// AttributedString makes for a custom face, which comes out optically larger
246250 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
247251 /// 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 {
249265 guard var attributed = try? AttributedString(
250266 markdown: text,
251267 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
252268 ) else {
253 return Text(text)
269 return AttributedString(text)
254270 }
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 }
258283 }
259 return Text(attributed)
284 return attributed
260285 }
261286
262287 private func headingFont(_ level: Int) -> Font {
@@ -287,6 +312,7 @@ struct MarkdownView: View {
287312/// a single line and rows overlap.
288313private struct MarkdownTableView: View {
289314 let table: MarkdownView.Table
315 var linkOptions: OrgRenderOptions?
290316
291317 private static let spacing: CGFloat = 14
292318
@@ -321,7 +347,7 @@ private struct MarkdownTableView: View {
321347
322348 private func cell(_ text: String, _ column: Int) -> some View {
323349 let alignment = table.alignments[column]
324 return MarkdownView.inline(text)
350 return MarkdownView.inline(text, linkOptions: linkOptions)
325351 .multilineTextAlignment(Self.textAlignment(alignment))
326352 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
327353 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Repos/ReadmeView.swift +6 −3
@@ -23,14 +23,14 @@ struct ReadmeView: View {
2323 // Qualified: `OrgView` here is org-mode, not the organization view.
2424 OrgSwiftUI.OrgView(
2525 Self.preferringPNGBadges(content, host: host),
26 options: orgOptions,
26 options: renderOptions,
2727 styler: OrgSyntaxStyler(colorScheme: colorScheme),
2828 // org's TODO/DONE are the same open/done distinction the forge already
2929 // colors, so they reuse those tokens rather than a third treatment.
3030 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
3131 )
3232 } else {
33 MarkdownView(markdown: content)
33 MarkdownView(markdown: content, linkOptions: renderOptions)
3434 }
3535 }
3636
@@ -55,7 +55,10 @@ struct ReadmeView: View {
5555
5656 /// gitbay serves raw bytes at `/{owner}/{repo}/raw/{ref}/…` and the file page at
5757 /// `/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 {
5962 let parts = repoPath.split(separator: "/", maxSplits: 1).map(String.init)
6063 guard parts.count == 2, !host.isEmpty else {
6164 return OrgRenderOptions()
gitbayTests/MarkdownTests.swift +45
@@ -1,3 +1,5 @@
1import Foundation
2import OrgSwift
13import Testing
24@testable import gitbay
35
@@ -90,3 +92,46 @@ struct MarkdownTableTests {
9092 #expect(parsed == [.paragraph("Run `a | b` to pipe. It is not a table.")])
9193 }
9294}
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}