krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-15T21:07:09Z

fix: fixes dead relative links in md/org with a new single-file viewer when tapped

Fixes: https://todo.sr.ht/~ccleberg/hutch/63
 Hutch.xcodeproj/project.pbxproj                    |  16 +-
 Hutch/Views/Repositories/FileTreeViewModel.swift   |  55 ++++++
 Hutch/Views/Repositories/LinkedFileSheetView.swift |  85 +++++++++
 .../Views/Repositories/MarkdownHTMLRenderer.swift  |  19 +-
 Hutch/Views/Repositories/ReadmeView.swift          | 203 ++++++++++++++++-----
 HutchTests/ReadmeViewTests.swift                   | 101 ++++++++++
 6 files changed, 421 insertions(+), 58 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index d223d18..8d8dc45 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 76;
+				CURRENT_PROJECT_VERSION = 77;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.8;
+				MARKETING_VERSION = 3.1.9;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 76;
+				CURRENT_PROJECT_VERSION = 77;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.8;
+				MARKETING_VERSION = 3.1.9;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 76;
+				CURRENT_PROJECT_VERSION = 77;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.8;
+				MARKETING_VERSION = 3.1.9;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 76;
+				CURRENT_PROJECT_VERSION = 77;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.8;
+				MARKETING_VERSION = 3.1.9;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
index 7330cd8..9ccd74b 100644
--- a/Hutch/Views/Repositories/FileTreeViewModel.swift
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -1,7 +1,25 @@
 import Foundation
 
+// MARK: - Linked file
+
+/// Identifies a repository file to fetch and display in a sheet.
+struct LinkedFileRequest: Identifiable, Sendable {
+    var id: String { "\(revspec):\(path)" }
+    let path: String
+    let revspec: String
+    let fileName: String
+}
+
 // MARK: - Response types (file-private to avoid @MainActor Decodable issues)
 
+private struct LinkedFileResponse: Decodable, Sendable {
+    let repository: LinkedFileRepository?
+}
+
+private struct LinkedFileRepository: Decodable, Sendable {
+    let path: TreeEntry?
+}
+
 private struct RevparseResponse: Decodable, Sendable {
     let repository: RevparseRepository?
 }
@@ -471,6 +489,43 @@ final class FileTreeViewModel {
         return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
     }
 
+    // MARK: - Linked File (used by README link interception)
+
+    private static let linkedFileQuery = """
+    query linkedFile($rid: ID!, $revspec: String, $path: String!) {
+        repository(rid: $rid) {
+            path(revspec: $revspec, path: $path) {
+                id
+                name
+                mode
+                object {
+                    __typename
+                    ... on TextBlob { text size }
+                    ... on BinaryBlob { size }
+                }
+            }
+        }
+    }
+    """
+
+    /// Fetch a single file by path using the `repository.path()` field.
+    /// Returns a `TreeEntry` on success, or throws on API/network error.
+    /// Returns `nil` if the path resolves to nothing (file not found).
+    func fetchLinkedFile(path: String, revspec: String) async throws -> TreeEntry? {
+        let variables: [String: any Sendable] = [
+            "rid": repositoryRid,
+            "revspec": revspec,
+            "path": path
+        ]
+        let result = try await client.execute(
+            service: service,
+            query: Self.linkedFileQuery,
+            variables: variables,
+            responseType: LinkedFileResponse.self
+        )
+        return result.repository?.path
+    }
+
     /// Dismiss the file view and go back to the directory listing.
     func dismissFileView() {
         viewingEntry = nil
diff --git a/Hutch/Views/Repositories/LinkedFileSheetView.swift b/Hutch/Views/Repositories/LinkedFileSheetView.swift
new file mode 100644
index 0000000..4f176be
--- /dev/null
+++ b/Hutch/Views/Repositories/LinkedFileSheetView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+/// A sheet that fetches and displays a single repository file by path,
+/// using the `repository.path()` GraphQL field — one API call, no tree traversal.
+struct LinkedFileSheetView: View {
+    let rid: String
+    let service: SRHTService
+    let client: SRHTClient
+    let request: LinkedFileRequest
+
+    @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapLines = false
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var entry: TreeEntry?
+    @State private var isLoading = true
+    @State private var error: String?
+
+    var body: some View {
+        NavigationStack {
+            Group {
+                if isLoading {
+                    SRHTLoadingStateView(message: "Loading \(request.fileName)…")
+                } else if let error {
+                    SRHTErrorStateView(
+                        title: "Couldn't Load File",
+                        message: error,
+                        retryAction: { await load() }
+                    )
+                } else if let entry, let object = entry.object {
+                    fileContentView(entry: entry, object: object)
+                } else {
+                    ContentUnavailableView(
+                        "File Not Found",
+                        systemImage: "doc.questionmark",
+                        description: Text("\(request.path) could not be found in this repository.")
+                    )
+                }
+            }
+            .navigationTitle(request.fileName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Done") { dismiss() }
+                }
+            }
+        }
+        .task { await load() }
+    }
+
+    private func load() async {
+        isLoading = true
+        error = nil
+        let vm = FileTreeViewModel(repositoryRid: rid, service: service, client: client)
+        do {
+            entry = try await vm.fetchLinkedFile(path: request.path, revspec: request.revspec)
+        } catch {
+            self.error = error.userFacingMessage
+        }
+        isLoading = false
+    }
+
+    @ViewBuilder
+    private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
+        switch object {
+        case .textBlob(let blob):
+            CodeFileTextView(
+                text: blob.text ?? "",
+                fileName: entry.name,
+                wrapLines: wrapLines
+            )
+        case .binaryBlob:
+            ContentUnavailableView(
+                "Binary File",
+                systemImage: "doc.zipper",
+                description: Text("Binary files cannot be displayed inline.")
+            )
+        default:
+            ContentUnavailableView(
+                "Unknown File",
+                systemImage: "questionmark.folder",
+                description: Text("This object type cannot be displayed.")
+            )
+        }
+    }
+}
diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
index 5c69659..bc63f9d 100644
--- a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
+++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
@@ -1,8 +1,12 @@
 import Markdown
 
-nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+nonisolated func markdownToHTML(
+    _ text: String,
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
+) -> String {
     let document = Document(parsing: text)
-    var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver)
+    var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
     return renderer.visit(document)
 }
 
@@ -10,12 +14,14 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
     typealias Result = String
 
     nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
+    nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
     nonisolated(unsafe) private var isRenderingTableHead = false
     nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
     nonisolated(unsafe) private var currentTableColumnIndex = 0
 
-    nonisolated init(imageURLResolver: ((String) -> String?)?) {
+    nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) {
         self.imageURLResolver = imageURLResolver
+        self.linkURLResolver = linkURLResolver
     }
 
     nonisolated mutating func visit(_ markup: Markup) -> String {
@@ -94,8 +100,11 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
 
     nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
         let content = visitChildren(of: link)
-        guard let destination = link.destination,
-              let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else {
+        guard let destination = link.destination else {
+            return content
+        }
+        let resolvedDestination = linkURLResolver?(destination) ?? destination
+        guard let sanitizedDestination = sanitizedReadmeLinkURLString(resolvedDestination) else {
             return content
         }
         let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 1a72b70..720dff0 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -7,6 +7,7 @@ struct ReadmeView: View {
 
     @Environment(\.colorScheme) private var colorScheme
     @State private var isShowingRepositoryDetails = false
+    @State private var linkedFile: LinkedFileRequest?
 
     var body: some View {
         ScrollView {
@@ -22,6 +23,14 @@ struct ReadmeView: View {
             }
             .padding()
         }
+        .sheet(item: $linkedFile) { request in
+            LinkedFileSheetView(
+                rid: viewModel.repository.rid,
+                service: viewModel.repository.service,
+                client: appState.client,
+                request: request
+            )
+        }
         .task {
             async let readme: () = viewModel.loadReadme()
             async let commits: () = viewModel.loadCommits()
@@ -128,7 +137,12 @@ struct ReadmeView: View {
                 readmePath: viewModel.readmePath,
                 colorScheme: colorScheme,
                 ownerCanonicalName: viewModel.repository.owner.canonicalName,
-                repositoryName: viewModel.repository.name
+                repositoryName: viewModel.repository.name,
+                onInterceptURL: { url in
+                    guard let request = parseLinkedFileRequest(url) else { return false }
+                    linkedFile = request
+                    return true
+                }
             )
         } else if let error = viewModel.error, !viewModel.readmeLoaded {
             SRHTErrorStateView(
@@ -145,6 +159,29 @@ struct ReadmeView: View {
         }
     }
 
+    /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest`
+    /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`.
+    /// Returns `nil` for any other URL (external links, fragment links, etc.).
+    private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? {
+        let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht"
+        guard let host = url.host, host == expectedHost else { return nil }
+
+        // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file
+        // → ["/", "~owner", "repo", "blob", "HEAD", "file"]
+        let parts = url.pathComponents
+        guard parts.count >= 6,
+              parts[1] == viewModel.repository.owner.canonicalName,
+              parts[2] == viewModel.repository.name,
+              parts[3] == "blob" else { return nil }
+
+        let revspec = parts[4]
+        let path = parts[5...].joined(separator: "/")
+        guard !path.isEmpty else { return nil }
+
+        let fileName = parts.last ?? path
+        return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName)
+    }
+
     private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
         switch content {
         case .html(let html):
@@ -192,6 +229,7 @@ struct RenderedMarkupContentView: View {
     let ownerCanonicalName: String
     let repositoryName: String
     var repositoryHost = "git.sr.ht"
+    var onInterceptURL: ((URL) -> Bool)? = nil
 
     @State private var renderedHTML: String?
 
@@ -212,10 +250,10 @@ struct RenderedMarkupContentView: View {
         Group {
             switch content {
             case .html(let html):
-                HTMLWebView(html: html, colorScheme: colorScheme)
+                HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
             case .markdown, .org:
                 if let renderedHTML {
-                    HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
+                    HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
                 } else {
                     SRHTLoadingStateView(message: "Preparing README…")
                 }
@@ -240,15 +278,27 @@ struct RenderedMarkupContentView: View {
                 return
             }
             let html = await Task.detached(priority: .userInitiated) {
-                markdownToHTML(text) { source in
-                    resolveRepositoryAssetURL(
-                        source,
-                        owner: ownerCanonicalName,
-                        repositoryName: repositoryName,
-                        readmePath: readmePath
-                    )?
-                    .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
-                }
+                markdownToHTML(
+                    text,
+                    imageURLResolver: { source in
+                        resolveRepositoryAssetURL(
+                            source,
+                            owner: ownerCanonicalName,
+                            repositoryName: repositoryName,
+                            readmePath: readmePath
+                        )?
+                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+                    },
+                    linkURLResolver: { source in
+                        resolveRepositoryLinkURL(
+                            source,
+                            owner: ownerCanonicalName,
+                            repositoryName: repositoryName,
+                            readmePath: readmePath
+                        )?
+                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+                    }
+                )
             }.value
             RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
             guard !Task.isCancelled else { return }
@@ -259,15 +309,27 @@ struct RenderedMarkupContentView: View {
                 return
             }
             let html = await Task.detached(priority: .userInitiated) {
-                orgToHTML(text) { source in
-                    resolveRepositoryAssetURL(
-                        source,
-                        owner: ownerCanonicalName,
-                        repositoryName: repositoryName,
-                        readmePath: readmePath
-                    )?
-                    .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
-                }
+                orgToHTML(
+                    text,
+                    imageURLResolver: { source in
+                        resolveRepositoryAssetURL(
+                            source,
+                            owner: ownerCanonicalName,
+                            repositoryName: repositoryName,
+                            readmePath: readmePath
+                        )?
+                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+                    },
+                    linkURLResolver: { source in
+                        resolveRepositoryLinkURL(
+                            source,
+                            owner: ownerCanonicalName,
+                            repositoryName: repositoryName,
+                            readmePath: readmePath
+                        )?
+                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+                    }
+                )
             }.value
             RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
             guard !Task.isCancelled else { return }
@@ -302,7 +364,11 @@ func clearWebContentRenderCaches() {
 
 // MARK: - Markdown to HTML
 
-nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+nonisolated func processInline(
+    _ text: String,
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
+) -> String {
 
     var protectedFragments: [String: String] = [:]
     var result = protectMatches(
@@ -330,7 +396,8 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
     result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
         let label = nsText.substring(with: match.range(at: 1))
         let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
-        guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
+        let resolvedURL = linkURLResolver?(rawURL) ?? rawURL
+        guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else {
             return label
         }
         return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
@@ -387,7 +454,11 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
 
 // MARK: - Org-mode to HTML
 
-nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+nonisolated func orgToHTML(
+    _ text: String,
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
+) -> String {
     let normalizedText = text
         .replacingOccurrences(of: "\r\n", with: "\n")
         .replacingOccurrences(of: "\r", with: "\n")
@@ -445,7 +516,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
     func closePendingBlockWrapper() {
         guard isWrappingBlockFigure else { return }
         if let activeBlockCaption {
-            html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver) + "</figcaption>\n"
+            html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
         }
         html += "</figure>\n"
         activeBlockCaption = nil
@@ -457,7 +528,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
             let normalizedParagraph = paragraph
                 .map { $0.trimmingCharacters(in: .whitespaces) }
                 .joined(separator: " ")
-            html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
+            html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
             paragraph = []
         }
     }
@@ -466,7 +537,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
         guard !currentListItemLines.isEmpty else { return }
         html += "<li>" + renderOrgListItemBody(
             currentListItemLines,
-            imageURLResolver: imageURLResolver
+            imageURLResolver: imageURLResolver,
+            linkURLResolver: linkURLResolver
         ) + "</li>\n"
         currentListItemLines = []
     }
@@ -489,7 +561,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
         beginPendingBlockWrapperIfNeeded()
         html += renderHTMLTable(
             rows: tableRows,
-            inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+            inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
         )
         closePendingBlockWrapper()
         tableRows = []
@@ -500,7 +572,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
         html += "<dl class=\"org-properties\">\n"
         for (key, value) in propertyRows {
             html += "<dt>" + escapeHTML(key) + "</dt>"
-            html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
+            html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
         }
         html += "</dl>\n"
         propertyRows = []
@@ -542,7 +614,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
     func closeVerseBlock() {
         if inVerseBlock {
             let content = verseLines
-                .map { processOrgInline($0, imageURLResolver: imageURLResolver) }
+                .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
                 .joined(separator: "\n")
             html += #"<blockquote class="org-verse">"# + "\n"
             html += content + "\n"
@@ -738,7 +810,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
             closeQuoteBlock()
             flushBlockState()
             let level = match.1.count
-            let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
+            let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
             html += "<h\(level)>" + content + "</h\(level)>\n"
             continue
         }
@@ -803,7 +875,11 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
     return html
 }
 
-nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+nonisolated private func processOrgInline(
+    _ text: String,
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
+) -> String {
     var result = escapeHTML(text)
     var protectedFragments: [String: String] = [:]
 
@@ -821,13 +897,19 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
         ) else {
             return source
         }
-        guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
+        let resolvedDestination = linkURLResolver?(destination) ?? destination
+        guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
             return imageHTML
         }
         return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
     }
 
-    result = protectOrgLinks(in: result, protectedFragments: &protectedFragments, imageURLResolver: imageURLResolver)
+    result = protectOrgLinks(
+        in: result,
+        protectedFragments: &protectedFragments,
+        imageURLResolver: imageURLResolver,
+        linkURLResolver: linkURLResolver
+    )
     result = protectMatches(
         in: result,
         pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
@@ -1067,7 +1149,8 @@ nonisolated private func orderedListItem(in line: String) -> String? {
 
 nonisolated private func renderOrgListItemBody(
     _ lines: [String],
-    imageURLResolver: ((String) -> String?)? = nil
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
 ) -> String {
     guard let firstLine = lines.first else { return "" }
 
@@ -1089,17 +1172,18 @@ nonisolated private func renderOrgListItemBody(
 
     var html = renderTaskListItem(
         contentLines.joined(separator: " "),
-        inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+        inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
     )
     if !nestedLines.isEmpty {
-        html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver)
+        html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
     }
     return html
 }
 
 nonisolated private func renderNestedOrgListHTML(
     _ lines: [String],
-    imageURLResolver: ((String) -> String?)? = nil
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
 ) -> String {
     var html = ""
     var listType: OrgListType?
@@ -1107,7 +1191,7 @@ nonisolated private func renderNestedOrgListHTML(
 
     func flushNestedItem() {
         guard !currentItemLines.isEmpty else { return }
-        html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n"
+        html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
         currentItemLines = []
     }
 
@@ -1160,7 +1244,8 @@ nonisolated private func renderNestedOrgListHTML(
 nonisolated private func protectOrgLinks(
     in text: String,
     protectedFragments: inout [String: String],
-    imageURLResolver: ((String) -> String?)? = nil
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
 ) -> String {
     var result = text
 
@@ -1172,7 +1257,8 @@ nonisolated private func protectOrgLinks(
         protectedFragments[token] = renderOrgLink(
             destination: parsed.destination,
             label: parsed.label,
-            imageURLResolver: imageURLResolver
+            imageURLResolver: imageURLResolver,
+            linkURLResolver: linkURLResolver
         )
         result.replaceSubrange(parsed.range, with: token)
     }
@@ -1221,12 +1307,14 @@ nonisolated private func parseOrgLink(
 nonisolated private func renderOrgLink(
     destination: String,
     label: String?,
-    imageURLResolver: ((String) -> String?)? = nil
+    imageURLResolver: ((String) -> String?)? = nil,
+    linkURLResolver: ((String) -> String?)? = nil
 ) -> String {
     if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
         let source = String(label.dropFirst(2).dropLast(2))
         if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
-            guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
+            let resolvedDestination = linkURLResolver?(destination) ?? destination
+            guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
                 return imageHTML
             }
             return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
@@ -1241,11 +1329,14 @@ nonisolated private func renderOrgLink(
         return imageHTML
     }
 
-    guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
+    let resolvedDestination = linkURLResolver?(destination) ?? destination
+    guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
         return label ?? destination
     }
 
-    let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination
+    let renderedLabel = label.map {
+        processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
+    } ?? destination
     return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
 }
 
@@ -1461,6 +1552,28 @@ nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
         .contains(where: { lowercased.hasSuffix($0) })
 }
 
+nonisolated func resolveRepositoryLinkURL(
+    _ source: String,
+    owner: String,
+    repositoryName: String,
+    readmePath: String?
+) -> String? {
+    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
+    guard !trimmedSource.isEmpty else { return nil }
+
+    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
+        || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
+        return trimmedSource
+    }
+
+    return resolveRepositoryAssetURL(
+        trimmedSource,
+        owner: owner,
+        repositoryName: repositoryName,
+        readmePath: readmePath
+    )
+}
+
 nonisolated func resolveRepositoryAssetURL(
     _ source: String,
     owner: String,
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
index f338525..3b9d80b 100644
--- a/HutchTests/ReadmeViewTests.swift
+++ b/HutchTests/ReadmeViewTests.swift
@@ -103,6 +103,44 @@ struct MarkdownRenderingTests {
         #expect(!html.contains("javascript:"))
     }
 
+    @Test
+    func markdownRelativeLinkWithoutResolverDropped() {
+        let html = markdownToHTML("[LICENSE](LICENSE)")
+
+        #expect(!html.contains("href="))
+        #expect(html.contains("LICENSE"))
+    }
+
+    @Test
+    func markdownRelativeLinkWithResolverRendersAnchor() {
+        let html = markdownToHTML(
+            "[LICENSE](LICENSE)",
+            linkURLResolver: { source in
+                source == "LICENSE" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE" : nil
+            }
+        )
+
+        #expect(html.contains(#"href="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE""#))
+        #expect(html.contains(">LICENSE</a>"))
+    }
+
+    @Test
+    func markdownFragmentLinkWithResolverPreservesFragment() {
+        let html = markdownToHTML(
+            "[section](#install)",
+            linkURLResolver: { source in
+                resolveRepositoryLinkURL(
+                    source,
+                    owner: "~ccleberg",
+                    repositoryName: "Hutch",
+                    readmePath: "README.md"
+                )
+            }
+        )
+
+        #expect(html.contains("href=\"#install\""))
+    }
+
     @Test
     func markdownImageRenders() {
         let html = markdownToHTML("![logo](https://example.com/logo.png)")
@@ -349,3 +387,66 @@ struct RepositoryAssetURLTests {
         #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
     }
 }
+
+struct RepositoryLinkURLTests {
+
+    @Test
+    func repositoryLinkURLResolvesRelativePath() {
+        let url = resolveRepositoryLinkURL(
+            "LICENSE",
+            owner: "~ccleberg",
+            repositoryName: "Hutch",
+            readmePath: "README.md"
+        )
+
+        #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
+    }
+
+    @Test
+    func repositoryLinkURLPassesThroughAbsoluteURL() {
+        let url = resolveRepositoryLinkURL(
+            "https://example.com/page",
+            owner: "~ccleberg",
+            repositoryName: "Hutch",
+            readmePath: "README.md"
+        )
+
+        #expect(url == "https://example.com/page")
+    }
+
+    @Test
+    func repositoryLinkURLPassesThroughFragment() {
+        let url = resolveRepositoryLinkURL(
+            "#install",
+            owner: "~ccleberg",
+            repositoryName: "Hutch",
+            readmePath: "README.md"
+        )
+
+        #expect(url == "#install")
+    }
+
+    @Test
+    func repositoryLinkURLPassesThroughMailto() {
+        let url = resolveRepositoryLinkURL(
+            "mailto:hello@example.com",
+            owner: "~ccleberg",
+            repositoryName: "Hutch",
+            readmePath: "README.md"
+        )
+
+        #expect(url == "mailto:hello@example.com")
+    }
+
+    @Test
+    func repositoryLinkURLResolvesSubdirectoryRelativePath() {
+        let url = resolveRepositoryLinkURL(
+            "docs/SECURITY.md",
+            owner: "~ccleberg",
+            repositoryName: "Hutch",
+            readmePath: "README.md"
+        )
+
+        #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
+    }
+}