summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-15 16:07:09 -0500
committerChristian Cleberg <[email protected]>2026-04-15 16:07:09 -0500
commit7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8 (patch)
treeeef9213c86b32d3b1b295bf503577fa362d6f1fc
parent06c36c4352f47655cb39a5042a7e5fd475c118e1 (diff)
downloadhutch-7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8.tar.gz
hutch-7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8.tar.bz2
hutch-7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8.zip
fix: fixes dead relative links in md/org with a new single-file viewer when tapped
Fixes: https://todo.sr.ht/~ccleberg/hutch/63
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift55
-rw-r--r--Hutch/Views/Repositories/LinkedFileSheetView.swift85
-rw-r--r--Hutch/Views/Repositories/MarkdownHTMLRenderer.swift19
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift203
-rw-r--r--HutchTests/ReadmeViewTests.swift101
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
@@ -104,6 +104,44 @@ struct MarkdownRenderingTests {
}
@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:[email protected]",
+ owner: "~ccleberg",
+ repositoryName: "Hutch",
+ readmePath: "README.md"
+ )
+
+ #expect(url == "mailto:[email protected]")
+ }
+
+ @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")
+ }
+}