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 @@
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}