Commit 9c3ddd4a21

9c3ddd4a2104fdd714a5c91f5a9ac41e80075084

parent: c4dd796588

Unsigned

cmc <hello@cleberg.net> · 2026-04-15 21:07 UTC

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

Fixes: https://todo.sr.ht/~ccleberg/hutch/63

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 76;
518 CURRENT_PROJECT_VERSION = 77;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 3.1.8;
535 MARKETING_VERSION = 3.1.9;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 76;
555 CURRENT_PROJECT_VERSION = 77;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 3.1.8;
572 MARKETING_VERSION = 3.1.9;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 76;
635 CURRENT_PROJECT_VERSION = 77;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 3.1.8;
645 MARKETING_VERSION = 3.1.9;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 76;
664 CURRENT_PROJECT_VERSION = 77;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 3.1.8;
674 MARKETING_VERSION = 3.1.9;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Views/Repositories/FileTreeViewModel.swift +55
@@ -1,7 +1,25 @@
11import Foundation
22
3// MARK: - Linked file
4
5/// Identifies a repository file to fetch and display in a sheet.
6struct LinkedFileRequest: Identifiable, Sendable {
7 var id: String { "\(revspec):\(path)" }
8 let path: String
9 let revspec: String
10 let fileName: String
11}
12
313// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
414
15private struct LinkedFileResponse: Decodable, Sendable {
16 let repository: LinkedFileRepository?
17}
18
19private struct LinkedFileRepository: Decodable, Sendable {
20 let path: TreeEntry?
21}
22
523private struct RevparseResponse: Decodable, Sendable {
624 let repository: RevparseRepository?
725}
@@ -471,6 +489,43 @@ final class FileTreeViewModel {
471489 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
472490 }
473491
492 // MARK: - Linked File (used by README link interception)
493
494 private static let linkedFileQuery = """
495 query linkedFile($rid: ID!, $revspec: String, $path: String!) {
496 repository(rid: $rid) {
497 path(revspec: $revspec, path: $path) {
498 id
499 name
500 mode
501 object {
502 __typename
503 ... on TextBlob { text size }
504 ... on BinaryBlob { size }
505 }
506 }
507 }
508 }
509 """
510
511 /// Fetch a single file by path using the `repository.path()` field.
512 /// Returns a `TreeEntry` on success, or throws on API/network error.
513 /// Returns `nil` if the path resolves to nothing (file not found).
514 func fetchLinkedFile(path: String, revspec: String) async throws -> TreeEntry? {
515 let variables: [String: any Sendable] = [
516 "rid": repositoryRid,
517 "revspec": revspec,
518 "path": path
519 ]
520 let result = try await client.execute(
521 service: service,
522 query: Self.linkedFileQuery,
523 variables: variables,
524 responseType: LinkedFileResponse.self
525 )
526 return result.repository?.path
527 }
528
474529 /// Dismiss the file view and go back to the directory listing.
475530 func dismissFileView() {
476531 viewingEntry = nil
Hutch/Views/Repositories/LinkedFileSheetView.swift added +85
@@ -0,0 +1,85 @@
1import SwiftUI
2
3/// A sheet that fetches and displays a single repository file by path,
4/// using the `repository.path()` GraphQL field — one API call, no tree traversal.
5struct LinkedFileSheetView: View {
6 let rid: String
7 let service: SRHTService
8 let client: SRHTClient
9 let request: LinkedFileRequest
10
11 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapLines = false
12 @Environment(\.dismiss) private var dismiss
13
14 @State private var entry: TreeEntry?
15 @State private var isLoading = true
16 @State private var error: String?
17
18 var body: some View {
19 NavigationStack {
20 Group {
21 if isLoading {
22 SRHTLoadingStateView(message: "Loading \(request.fileName)…")
23 } else if let error {
24 SRHTErrorStateView(
25 title: "Couldn't Load File",
26 message: error,
27 retryAction: { await load() }
28 )
29 } else if let entry, let object = entry.object {
30 fileContentView(entry: entry, object: object)
31 } else {
32 ContentUnavailableView(
33 "File Not Found",
34 systemImage: "doc.questionmark",
35 description: Text("\(request.path) could not be found in this repository.")
36 )
37 }
38 }
39 .navigationTitle(request.fileName)
40 .navigationBarTitleDisplayMode(.inline)
41 .toolbar {
42 ToolbarItem(placement: .topBarTrailing) {
43 Button("Done") { dismiss() }
44 }
45 }
46 }
47 .task { await load() }
48 }
49
50 private func load() async {
51 isLoading = true
52 error = nil
53 let vm = FileTreeViewModel(repositoryRid: rid, service: service, client: client)
54 do {
55 entry = try await vm.fetchLinkedFile(path: request.path, revspec: request.revspec)
56 } catch {
57 self.error = error.userFacingMessage
58 }
59 isLoading = false
60 }
61
62 @ViewBuilder
63 private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
64 switch object {
65 case .textBlob(let blob):
66 CodeFileTextView(
67 text: blob.text ?? "",
68 fileName: entry.name,
69 wrapLines: wrapLines
70 )
71 case .binaryBlob:
72 ContentUnavailableView(
73 "Binary File",
74 systemImage: "doc.zipper",
75 description: Text("Binary files cannot be displayed inline.")
76 )
77 default:
78 ContentUnavailableView(
79 "Unknown File",
80 systemImage: "questionmark.folder",
81 description: Text("This object type cannot be displayed.")
82 )
83 }
84 }
85}
Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +14 −5
@@ -1,8 +1,12 @@
11import Markdown
22
3nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
3nonisolated func markdownToHTML(
4 _ text: String,
5 imageURLResolver: ((String) -> String?)? = nil,
6 linkURLResolver: ((String) -> String?)? = nil
7) -> String {
48 let document = Document(parsing: text)
5 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver)
9 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
610 return renderer.visit(document)
711}
812
@@ -10,12 +14,14 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
1014 typealias Result = String
1115
1216 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
17 nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
1318 nonisolated(unsafe) private var isRenderingTableHead = false
1419 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
1520 nonisolated(unsafe) private var currentTableColumnIndex = 0
1621
17 nonisolated init(imageURLResolver: ((String) -> String?)?) {
22 nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) {
1823 self.imageURLResolver = imageURLResolver
24 self.linkURLResolver = linkURLResolver
1925 }
2026
2127 nonisolated mutating func visit(_ markup: Markup) -> String {
@@ -94,8 +100,11 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
94100
95101 nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
96102 let content = visitChildren(of: link)
97 guard let destination = link.destination,
98 let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else {
103 guard let destination = link.destination else {
104 return content
105 }
106 let resolvedDestination = linkURLResolver?(destination) ?? destination
107 guard let sanitizedDestination = sanitizedReadmeLinkURLString(resolvedDestination) else {
99108 return content
100109 }
101110 let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
Hutch/Views/Repositories/ReadmeView.swift +158 −45
@@ -7,6 +7,7 @@ struct ReadmeView: View {
77
88 @Environment(\.colorScheme) private var colorScheme
99 @State private var isShowingRepositoryDetails = false
10 @State private var linkedFile: LinkedFileRequest?
1011
1112 var body: some View {
1213 ScrollView {
@@ -22,6 +23,14 @@ struct ReadmeView: View {
2223 }
2324 .padding()
2425 }
26 .sheet(item: $linkedFile) { request in
27 LinkedFileSheetView(
28 rid: viewModel.repository.rid,
29 service: viewModel.repository.service,
30 client: appState.client,
31 request: request
32 )
33 }
2534 .task {
2635 async let readme: () = viewModel.loadReadme()
2736 async let commits: () = viewModel.loadCommits()
@@ -128,7 +137,12 @@ struct ReadmeView: View {
128137 readmePath: viewModel.readmePath,
129138 colorScheme: colorScheme,
130139 ownerCanonicalName: viewModel.repository.owner.canonicalName,
131 repositoryName: viewModel.repository.name
140 repositoryName: viewModel.repository.name,
141 onInterceptURL: { url in
142 guard let request = parseLinkedFileRequest(url) else { return false }
143 linkedFile = request
144 return true
145 }
132146 )
133147 } else if let error = viewModel.error, !viewModel.readmeLoaded {
134148 SRHTErrorStateView(
@@ -145,6 +159,29 @@ struct ReadmeView: View {
145159 }
146160 }
147161
162 /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest`
163 /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`.
164 /// Returns `nil` for any other URL (external links, fragment links, etc.).
165 private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? {
166 let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht"
167 guard let host = url.host, host == expectedHost else { return nil }
168
169 // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file
170 // → ["/", "~owner", "repo", "blob", "HEAD", "file"]
171 let parts = url.pathComponents
172 guard parts.count >= 6,
173 parts[1] == viewModel.repository.owner.canonicalName,
174 parts[2] == viewModel.repository.name,
175 parts[3] == "blob" else { return nil }
176
177 let revspec = parts[4]
178 let path = parts[5...].joined(separator: "/")
179 guard !path.isEmpty else { return nil }
180
181 let fileName = parts.last ?? path
182 return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName)
183 }
184
148185 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
149186 switch content {
150187 case .html(let html):
@@ -192,6 +229,7 @@ struct RenderedMarkupContentView: View {
192229 let ownerCanonicalName: String
193230 let repositoryName: String
194231 var repositoryHost = "git.sr.ht"
232 var onInterceptURL: ((URL) -> Bool)? = nil
195233
196234 @State private var renderedHTML: String?
197235
@@ -212,10 +250,10 @@ struct RenderedMarkupContentView: View {
212250 Group {
213251 switch content {
214252 case .html(let html):
215 HTMLWebView(html: html, colorScheme: colorScheme)
253 HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
216254 case .markdown, .org:
217255 if let renderedHTML {
218 HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
256 HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
219257 } else {
220258 SRHTLoadingStateView(message: "Preparing README…")
221259 }
@@ -240,15 +278,27 @@ struct RenderedMarkupContentView: View {
240278 return
241279 }
242280 let html = await Task.detached(priority: .userInitiated) {
243 markdownToHTML(text) { source in
244 resolveRepositoryAssetURL(
245 source,
246 owner: ownerCanonicalName,
247 repositoryName: repositoryName,
248 readmePath: readmePath
249 )?
250 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
251 }
281 markdownToHTML(
282 text,
283 imageURLResolver: { source in
284 resolveRepositoryAssetURL(
285 source,
286 owner: ownerCanonicalName,
287 repositoryName: repositoryName,
288 readmePath: readmePath
289 )?
290 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
291 },
292 linkURLResolver: { source in
293 resolveRepositoryLinkURL(
294 source,
295 owner: ownerCanonicalName,
296 repositoryName: repositoryName,
297 readmePath: readmePath
298 )?
299 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
300 }
301 )
252302 }.value
253303 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
254304 guard !Task.isCancelled else { return }
@@ -259,15 +309,27 @@ struct RenderedMarkupContentView: View {
259309 return
260310 }
261311 let html = await Task.detached(priority: .userInitiated) {
262 orgToHTML(text) { source in
263 resolveRepositoryAssetURL(
264 source,
265 owner: ownerCanonicalName,
266 repositoryName: repositoryName,
267 readmePath: readmePath
268 )?
269 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
270 }
312 orgToHTML(
313 text,
314 imageURLResolver: { source in
315 resolveRepositoryAssetURL(
316 source,
317 owner: ownerCanonicalName,
318 repositoryName: repositoryName,
319 readmePath: readmePath
320 )?
321 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
322 },
323 linkURLResolver: { source in
324 resolveRepositoryLinkURL(
325 source,
326 owner: ownerCanonicalName,
327 repositoryName: repositoryName,
328 readmePath: readmePath
329 )?
330 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
331 }
332 )
271333 }.value
272334 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
273335 guard !Task.isCancelled else { return }
@@ -302,7 +364,11 @@ func clearWebContentRenderCaches() {
302364
303365// MARK: - Markdown to HTML
304366
305nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
367nonisolated func processInline(
368 _ text: String,
369 imageURLResolver: ((String) -> String?)? = nil,
370 linkURLResolver: ((String) -> String?)? = nil
371) -> String {
306372
307373 var protectedFragments: [String: String] = [:]
308374 var result = protectMatches(
@@ -330,7 +396,8 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
330396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
331397 let label = nsText.substring(with: match.range(at: 1))
332398 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
333 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
399 let resolvedURL = linkURLResolver?(rawURL) ?? rawURL
400 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else {
334401 return label
335402 }
336403 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
@@ -387,7 +454,11 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
387454
388455// MARK: - Org-mode to HTML
389456
390nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
457nonisolated func orgToHTML(
458 _ text: String,
459 imageURLResolver: ((String) -> String?)? = nil,
460 linkURLResolver: ((String) -> String?)? = nil
461) -> String {
391462 let normalizedText = text
392463 .replacingOccurrences(of: "\r\n", with: "\n")
393464 .replacingOccurrences(of: "\r", with: "\n")
@@ -445,7 +516,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
445516 func closePendingBlockWrapper() {
446517 guard isWrappingBlockFigure else { return }
447518 if let activeBlockCaption {
448 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver) + "</figcaption>\n"
519 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
449520 }
450521 html += "</figure>\n"
451522 activeBlockCaption = nil
@@ -457,7 +528,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
457528 let normalizedParagraph = paragraph
458529 .map { $0.trimmingCharacters(in: .whitespaces) }
459530 .joined(separator: " ")
460 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
531 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
461532 paragraph = []
462533 }
463534 }
@@ -466,7 +537,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
466537 guard !currentListItemLines.isEmpty else { return }
467538 html += "<li>" + renderOrgListItemBody(
468539 currentListItemLines,
469 imageURLResolver: imageURLResolver
540 imageURLResolver: imageURLResolver,
541 linkURLResolver: linkURLResolver
470542 ) + "</li>\n"
471543 currentListItemLines = []
472544 }
@@ -489,7 +561,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
489561 beginPendingBlockWrapperIfNeeded()
490562 html += renderHTMLTable(
491563 rows: tableRows,
492 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
564 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
493565 )
494566 closePendingBlockWrapper()
495567 tableRows = []
@@ -500,7 +572,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
500572 html += "<dl class=\"org-properties\">\n"
501573 for (key, value) in propertyRows {
502574 html += "<dt>" + escapeHTML(key) + "</dt>"
503 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
575 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
504576 }
505577 html += "</dl>\n"
506578 propertyRows = []
@@ -542,7 +614,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
542614 func closeVerseBlock() {
543615 if inVerseBlock {
544616 let content = verseLines
545 .map { processOrgInline($0, imageURLResolver: imageURLResolver) }
617 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
546618 .joined(separator: "\n")
547619 html += #"<blockquote class="org-verse">"# + "\n"
548620 html += content + "\n"
@@ -738,7 +810,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
738810 closeQuoteBlock()
739811 flushBlockState()
740812 let level = match.1.count
741 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
813 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
742814 html += "<h\(level)>" + content + "</h\(level)>\n"
743815 continue
744816 }
@@ -803,7 +875,11 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
803875 return html
804876}
805877
806nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
878nonisolated private func processOrgInline(
879 _ text: String,
880 imageURLResolver: ((String) -> String?)? = nil,
881 linkURLResolver: ((String) -> String?)? = nil
882) -> String {
807883 var result = escapeHTML(text)
808884 var protectedFragments: [String: String] = [:]
809885
@@ -821,13 +897,19 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
821897 ) else {
822898 return source
823899 }
824 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
900 let resolvedDestination = linkURLResolver?(destination) ?? destination
901 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
825902 return imageHTML
826903 }
827904 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
828905 }
829906
830 result = protectOrgLinks(in: result, protectedFragments: &protectedFragments, imageURLResolver: imageURLResolver)
907 result = protectOrgLinks(
908 in: result,
909 protectedFragments: &protectedFragments,
910 imageURLResolver: imageURLResolver,
911 linkURLResolver: linkURLResolver
912 )
831913 result = protectMatches(
832914 in: result,
833915 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
@@ -1067,7 +1149,8 @@ nonisolated private func orderedListItem(in line: String) -> String? {
10671149
10681150nonisolated private func renderOrgListItemBody(
10691151 _ lines: [String],
1070 imageURLResolver: ((String) -> String?)? = nil
1152 imageURLResolver: ((String) -> String?)? = nil,
1153 linkURLResolver: ((String) -> String?)? = nil
10711154) -> String {
10721155 guard let firstLine = lines.first else { return "" }
10731156
@@ -1089,17 +1172,18 @@ nonisolated private func renderOrgListItemBody(
10891172
10901173 var html = renderTaskListItem(
10911174 contentLines.joined(separator: " "),
1092 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
1175 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
10931176 )
10941177 if !nestedLines.isEmpty {
1095 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver)
1178 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
10961179 }
10971180 return html
10981181}
10991182
11001183nonisolated private func renderNestedOrgListHTML(
11011184 _ lines: [String],
1102 imageURLResolver: ((String) -> String?)? = nil
1185 imageURLResolver: ((String) -> String?)? = nil,
1186 linkURLResolver: ((String) -> String?)? = nil
11031187) -> String {
11041188 var html = ""
11051189 var listType: OrgListType?
@@ -1107,7 +1191,7 @@ nonisolated private func renderNestedOrgListHTML(
11071191
11081192 func flushNestedItem() {
11091193 guard !currentItemLines.isEmpty else { return }
1110 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n"
1194 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
11111195 currentItemLines = []
11121196 }
11131197
@@ -1160,7 +1244,8 @@ nonisolated private func renderNestedOrgListHTML(
11601244nonisolated private func protectOrgLinks(
11611245 in text: String,
11621246 protectedFragments: inout [String: String],
1163 imageURLResolver: ((String) -> String?)? = nil
1247 imageURLResolver: ((String) -> String?)? = nil,
1248 linkURLResolver: ((String) -> String?)? = nil
11641249) -> String {
11651250 var result = text
11661251
@@ -1172,7 +1257,8 @@ nonisolated private func protectOrgLinks(
11721257 protectedFragments[token] = renderOrgLink(
11731258 destination: parsed.destination,
11741259 label: parsed.label,
1175 imageURLResolver: imageURLResolver
1260 imageURLResolver: imageURLResolver,
1261 linkURLResolver: linkURLResolver
11761262 )
11771263 result.replaceSubrange(parsed.range, with: token)
11781264 }
@@ -1221,12 +1307,14 @@ nonisolated private func parseOrgLink(
12211307nonisolated private func renderOrgLink(
12221308 destination: String,
12231309 label: String?,
1224 imageURLResolver: ((String) -> String?)? = nil
1310 imageURLResolver: ((String) -> String?)? = nil,
1311 linkURLResolver: ((String) -> String?)? = nil
12251312) -> String {
12261313 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
12271314 let source = String(label.dropFirst(2).dropLast(2))
12281315 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1229 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1316 let resolvedDestination = linkURLResolver?(destination) ?? destination
1317 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
12301318 return imageHTML
12311319 }
12321320 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
@@ -1241,11 +1329,14 @@ nonisolated private func renderOrgLink(
12411329 return imageHTML
12421330 }
12431331
1244 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1332 let resolvedDestination = linkURLResolver?(destination) ?? destination
1333 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
12451334 return label ?? destination
12461335 }
12471336
1248 let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination
1337 let renderedLabel = label.map {
1338 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1339 } ?? destination
12491340 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
12501341}
12511342
@@ -1461,6 +1552,28 @@ nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
14611552 .contains(where: { lowercased.hasSuffix($0) })
14621553}
14631554
1555nonisolated func resolveRepositoryLinkURL(
1556 _ source: String,
1557 owner: String,
1558 repositoryName: String,
1559 readmePath: String?
1560) -> String? {
1561 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1562 guard !trimmedSource.isEmpty else { return nil }
1563
1564 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
1565 || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
1566 return trimmedSource
1567 }
1568
1569 return resolveRepositoryAssetURL(
1570 trimmedSource,
1571 owner: owner,
1572 repositoryName: repositoryName,
1573 readmePath: readmePath
1574 )
1575}
1576
14641577nonisolated func resolveRepositoryAssetURL(
14651578 _ source: String,
14661579 owner: String,
HutchTests/ReadmeViewTests.swift +101
@@ -103,6 +103,44 @@ struct MarkdownRenderingTests {
103103 #expect(!html.contains("javascript:"))
104104 }
105105
106 @Test
107 func markdownRelativeLinkWithoutResolverDropped() {
108 let html = markdownToHTML("[LICENSE](LICENSE)")
109
110 #expect(!html.contains("href="))
111 #expect(html.contains("LICENSE"))
112 }
113
114 @Test
115 func markdownRelativeLinkWithResolverRendersAnchor() {
116 let html = markdownToHTML(
117 "[LICENSE](LICENSE)",
118 linkURLResolver: { source in
119 source == "LICENSE" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE" : nil
120 }
121 )
122
123 #expect(html.contains(#"href="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE""#))
124 #expect(html.contains(">LICENSE</a>"))
125 }
126
127 @Test
128 func markdownFragmentLinkWithResolverPreservesFragment() {
129 let html = markdownToHTML(
130 "[section](#install)",
131 linkURLResolver: { source in
132 resolveRepositoryLinkURL(
133 source,
134 owner: "~ccleberg",
135 repositoryName: "Hutch",
136 readmePath: "README.md"
137 )
138 }
139 )
140
141 #expect(html.contains("href=\"#install\""))
142 }
143
106144 @Test
107145 func markdownImageRenders() {
108146 let html = markdownToHTML("![logo](https://example.com/logo.png)")
@@ -349,3 +387,66 @@ struct RepositoryAssetURLTests {
349387 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
350388 }
351389}
390
391struct RepositoryLinkURLTests {
392
393 @Test
394 func repositoryLinkURLResolvesRelativePath() {
395 let url = resolveRepositoryLinkURL(
396 "LICENSE",
397 owner: "~ccleberg",
398 repositoryName: "Hutch",
399 readmePath: "README.md"
400 )
401
402 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
403 }
404
405 @Test
406 func repositoryLinkURLPassesThroughAbsoluteURL() {
407 let url = resolveRepositoryLinkURL(
408 "https://example.com/page",
409 owner: "~ccleberg",
410 repositoryName: "Hutch",
411 readmePath: "README.md"
412 )
413
414 #expect(url == "https://example.com/page")
415 }
416
417 @Test
418 func repositoryLinkURLPassesThroughFragment() {
419 let url = resolveRepositoryLinkURL(
420 "#install",
421 owner: "~ccleberg",
422 repositoryName: "Hutch",
423 readmePath: "README.md"
424 )
425
426 #expect(url == "#install")
427 }
428
429 @Test
430 func repositoryLinkURLPassesThroughMailto() {
431 let url = resolveRepositoryLinkURL(
432 "mailto:hello@example.com",
433 owner: "~ccleberg",
434 repositoryName: "Hutch",
435 readmePath: "README.md"
436 )
437
438 #expect(url == "mailto:hello@example.com")
439 }
440
441 @Test
442 func repositoryLinkURLResolvesSubdirectoryRelativePath() {
443 let url = resolveRepositoryLinkURL(
444 "docs/SECURITY.md",
445 owner: "~ccleberg",
446 repositoryName: "Hutch",
447 readmePath: "README.md"
448 )
449
450 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
451 }
452}