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 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 76; 518 CURRENT_PROJECT_VERSION = 77;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 3.1.8; 535 MARKETING_VERSION = 3.1.9;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 76; 555 CURRENT_PROJECT_VERSION = 77;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 3.1.8; 572 MARKETING_VERSION = 3.1.9;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 76; 635 CURRENT_PROJECT_VERSION = 77;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 3.1.8; 645 MARKETING_VERSION = 3.1.9;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 76; 664 CURRENT_PROJECT_VERSION = 77;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 3.1.8; 674 MARKETING_VERSION = 3.1.9;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/Views/Repositories/FileTreeViewModel.swift +55
@@ -1,7 +1,25 @@
1import Foundation 1import Foundation
2 2
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
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues) 13// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4 14
15private struct LinkedFileResponse: Decodable, Sendable {
16 let repository: LinkedFileRepository?
17}
18
19private struct LinkedFileRepository: Decodable, Sendable {
20 let path: TreeEntry?
21}
22
5private struct RevparseResponse: Decodable, Sendable { 23private struct RevparseResponse: Decodable, Sendable {
6 let repository: RevparseRepository? 24 let repository: RevparseRepository?
7} 25}
@@ -471,6 +489,43 @@ final class FileTreeViewModel {
471 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) 489 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
472 } 490 }
473 491
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
474 /// Dismiss the file view and go back to the directory listing. 529 /// Dismiss the file view and go back to the directory listing.
475 func dismissFileView() { 530 func dismissFileView() {
476 viewingEntry = nil 531 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 @@
1import Markdown 1import Markdown
2 2
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 {
4 let document = Document(parsing: text) 8 let document = Document(parsing: text)
5 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver) 9 var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
6 return renderer.visit(document) 10 return renderer.visit(document)
7} 11}
8 12
@@ -10,12 +14,14 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
10 typealias Result = String 14 typealias Result = String
11 15
12 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? 16 nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
17 nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
13 nonisolated(unsafe) private var isRenderingTableHead = false 18 nonisolated(unsafe) private var isRenderingTableHead = false
14 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = [] 19 nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
15 nonisolated(unsafe) private var currentTableColumnIndex = 0 20 nonisolated(unsafe) private var currentTableColumnIndex = 0
16 21
17 nonisolated init(imageURLResolver: ((String) -> String?)?) { 22 nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) {
18 self.imageURLResolver = imageURLResolver 23 self.imageURLResolver = imageURLResolver
24 self.linkURLResolver = linkURLResolver
19 } 25 }
20 26
21 nonisolated mutating func visit(_ markup: Markup) -> String { 27 nonisolated mutating func visit(_ markup: Markup) -> String {
@@ -94,8 +100,11 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
94 100
95 nonisolated mutating func visitLink(_ link: Markdown.Link) -> String { 101 nonisolated mutating func visitLink(_ link: Markdown.Link) -> String {
96 let content = visitChildren(of: link) 102 let content = visitChildren(of: link)
97 guard let destination = link.destination, 103 guard let destination = link.destination else {
98 let sanitizedDestination = sanitizedReadmeLinkURLString(destination) else { 104 return content
105 }
106 let resolvedDestination = linkURLResolver?(destination) ?? destination
107 guard let sanitizedDestination = sanitizedReadmeLinkURLString(resolvedDestination) else {
99 return content 108 return content
100 } 109 }
101 let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination)) 110 let href = escapeHTMLAttribute(decodeHTMLEntities(sanitizedDestination))
Hutch/Views/Repositories/ReadmeView.swift +158 −45
@@ -7,6 +7,7 @@ struct ReadmeView: View {
7 7
8 @Environment(\.colorScheme) private var colorScheme 8 @Environment(\.colorScheme) private var colorScheme
9 @State private var isShowingRepositoryDetails = false 9 @State private var isShowingRepositoryDetails = false
10 @State private var linkedFile: LinkedFileRequest?
10 11
11 var body: some View { 12 var body: some View {
12 ScrollView { 13 ScrollView {
@@ -22,6 +23,14 @@ struct ReadmeView: View {
22 } 23 }
23 .padding() 24 .padding()
24 } 25 }
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 }
25 .task { 34 .task {
26 async let readme: () = viewModel.loadReadme() 35 async let readme: () = viewModel.loadReadme()
27 async let commits: () = viewModel.loadCommits() 36 async let commits: () = viewModel.loadCommits()
@@ -128,7 +137,12 @@ struct ReadmeView: View {
128 readmePath: viewModel.readmePath, 137 readmePath: viewModel.readmePath,
129 colorScheme: colorScheme, 138 colorScheme: colorScheme,
130 ownerCanonicalName: viewModel.repository.owner.canonicalName, 139 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 }
132 ) 146 )
133 } else if let error = viewModel.error, !viewModel.readmeLoaded { 147 } else if let error = viewModel.error, !viewModel.readmeLoaded {
134 SRHTErrorStateView( 148 SRHTErrorStateView(
@@ -145,6 +159,29 @@ struct ReadmeView: View {
145 } 159 }
146 } 160 }
147 161
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
148 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { 185 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
149 switch content { 186 switch content {
150 case .html(let html): 187 case .html(let html):
@@ -192,6 +229,7 @@ struct RenderedMarkupContentView: View {
192 let ownerCanonicalName: String 229 let ownerCanonicalName: String
193 let repositoryName: String 230 let repositoryName: String
194 var repositoryHost = "git.sr.ht" 231 var repositoryHost = "git.sr.ht"
232 var onInterceptURL: ((URL) -> Bool)? = nil
195 233
196 @State private var renderedHTML: String? 234 @State private var renderedHTML: String?
197 235
@@ -212,10 +250,10 @@ struct RenderedMarkupContentView: View {
212 Group { 250 Group {
213 switch content { 251 switch content {
214 case .html(let html): 252 case .html(let html):
215 HTMLWebView(html: html, colorScheme: colorScheme) 253 HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
216 case .markdown, .org: 254 case .markdown, .org:
217 if let renderedHTML { 255 if let renderedHTML {
218 HTMLWebView(html: renderedHTML, colorScheme: colorScheme) 256 HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
219 } else { 257 } else {
220 SRHTLoadingStateView(message: "Preparing README…") 258 SRHTLoadingStateView(message: "Preparing README…")
221 } 259 }
@@ -240,15 +278,27 @@ struct RenderedMarkupContentView: View {
240 return 278 return
241 } 279 }
242 let html = await Task.detached(priority: .userInitiated) { 280 let html = await Task.detached(priority: .userInitiated) {
243 markdownToHTML(text) { source in 281 markdownToHTML(
244 resolveRepositoryAssetURL( 282 text,
245 source, 283 imageURLResolver: { source in
246 owner: ownerCanonicalName, 284 resolveRepositoryAssetURL(
247 repositoryName: repositoryName, 285 source,
248 readmePath: readmePath 286 owner: ownerCanonicalName,
249 )? 287 repositoryName: repositoryName,
250 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) 288 readmePath: readmePath
251 } 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 )
252 }.value 302 }.value
253 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) 303 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
254 guard !Task.isCancelled else { return } 304 guard !Task.isCancelled else { return }
@@ -259,15 +309,27 @@ struct RenderedMarkupContentView: View {
259 return 309 return
260 } 310 }
261 let html = await Task.detached(priority: .userInitiated) { 311 let html = await Task.detached(priority: .userInitiated) {
262 orgToHTML(text) { source in 312 orgToHTML(
263 resolveRepositoryAssetURL( 313 text,
264 source, 314 imageURLResolver: { source in
265 owner: ownerCanonicalName, 315 resolveRepositoryAssetURL(
266 repositoryName: repositoryName, 316 source,
267 readmePath: readmePath 317 owner: ownerCanonicalName,
268 )? 318 repositoryName: repositoryName,
269 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) 319 readmePath: readmePath
270 } 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 )
271 }.value 333 }.value
272 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) 334 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
273 guard !Task.isCancelled else { return } 335 guard !Task.isCancelled else { return }
@@ -302,7 +364,11 @@ func clearWebContentRenderCaches() {
302 364
303// MARK: - Markdown to HTML 365// MARK: - Markdown to HTML
304 366
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 {
306 372
307 var protectedFragments: [String: String] = [:] 373 var protectedFragments: [String: String] = [:]
308 var result = protectMatches( 374 var result = protectMatches(
@@ -330,7 +396,8 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
330 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in 396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
331 let label = nsText.substring(with: match.range(at: 1)) 397 let label = nsText.substring(with: match.range(at: 1))
332 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) 398 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 {
334 return label 401 return label
335 } 402 }
336 return #"<a href="\#(sanitizedURL)">\#(label)</a>"# 403 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
@@ -387,7 +454,11 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
387 454
388// MARK: - Org-mode to HTML 455// MARK: - Org-mode to HTML
389 456
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 {
391 let normalizedText = text 462 let normalizedText = text
392 .replacingOccurrences(of: "\r\n", with: "\n") 463 .replacingOccurrences(of: "\r\n", with: "\n")
393 .replacingOccurrences(of: "\r", with: "\n") 464 .replacingOccurrences(of: "\r", with: "\n")
@@ -445,7 +516,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
445 func closePendingBlockWrapper() { 516 func closePendingBlockWrapper() {
446 guard isWrappingBlockFigure else { return } 517 guard isWrappingBlockFigure else { return }
447 if let activeBlockCaption { 518 if let activeBlockCaption {
448 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver) + "</figcaption>\n" 519 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
449 } 520 }
450 html += "</figure>\n" 521 html += "</figure>\n"
451 activeBlockCaption = nil 522 activeBlockCaption = nil
@@ -457,7 +528,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
457 let normalizedParagraph = paragraph 528 let normalizedParagraph = paragraph
458 .map { $0.trimmingCharacters(in: .whitespaces) } 529 .map { $0.trimmingCharacters(in: .whitespaces) }
459 .joined(separator: " ") 530 .joined(separator: " ")
460 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n" 531 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
461 paragraph = [] 532 paragraph = []
462 } 533 }
463 } 534 }
@@ -466,7 +537,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
466 guard !currentListItemLines.isEmpty else { return } 537 guard !currentListItemLines.isEmpty else { return }
467 html += "<li>" + renderOrgListItemBody( 538 html += "<li>" + renderOrgListItemBody(
468 currentListItemLines, 539 currentListItemLines,
469 imageURLResolver: imageURLResolver 540 imageURLResolver: imageURLResolver,
541 linkURLResolver: linkURLResolver
470 ) + "</li>\n" 542 ) + "</li>\n"
471 currentListItemLines = [] 543 currentListItemLines = []
472 } 544 }
@@ -489,7 +561,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
489 beginPendingBlockWrapperIfNeeded() 561 beginPendingBlockWrapperIfNeeded()
490 html += renderHTMLTable( 562 html += renderHTMLTable(
491 rows: tableRows, 563 rows: tableRows,
492 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } 564 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
493 ) 565 )
494 closePendingBlockWrapper() 566 closePendingBlockWrapper()
495 tableRows = [] 567 tableRows = []
@@ -500,7 +572,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
500 html += "<dl class=\"org-properties\">\n" 572 html += "<dl class=\"org-properties\">\n"
501 for (key, value) in propertyRows { 573 for (key, value) in propertyRows {
502 html += "<dt>" + escapeHTML(key) + "</dt>" 574 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"
504 } 576 }
505 html += "</dl>\n" 577 html += "</dl>\n"
506 propertyRows = [] 578 propertyRows = []
@@ -542,7 +614,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
542 func closeVerseBlock() { 614 func closeVerseBlock() {
543 if inVerseBlock { 615 if inVerseBlock {
544 let content = verseLines 616 let content = verseLines
545 .map { processOrgInline($0, imageURLResolver: imageURLResolver) } 617 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
546 .joined(separator: "\n") 618 .joined(separator: "\n")
547 html += #"<blockquote class="org-verse">"# + "\n" 619 html += #"<blockquote class="org-verse">"# + "\n"
548 html += content + "\n" 620 html += content + "\n"
@@ -738,7 +810,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
738 closeQuoteBlock() 810 closeQuoteBlock()
739 flushBlockState() 811 flushBlockState()
740 let level = match.1.count 812 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)
742 html += "<h\(level)>" + content + "</h\(level)>\n" 814 html += "<h\(level)>" + content + "</h\(level)>\n"
743 continue 815 continue
744 } 816 }
@@ -803,7 +875,11 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
803 return html 875 return html
804} 876}
805 877
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 {
807 var result = escapeHTML(text) 883 var result = escapeHTML(text)
808 var protectedFragments: [String: String] = [:] 884 var protectedFragments: [String: String] = [:]
809 885
@@ -821,13 +897,19 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
821 ) else { 897 ) else {
822 return source 898 return source
823 } 899 }
824 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else { 900 let resolvedDestination = linkURLResolver?(destination) ?? destination
901 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
825 return imageHTML 902 return imageHTML
826 } 903 }
827 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# 904 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
828 } 905 }
829 906
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 )
831 result = protectMatches( 913 result = protectMatches(
832 in: result, 914 in: result,
833 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, 915 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
@@ -1067,7 +1149,8 @@ nonisolated private func orderedListItem(in line: String) -> String? {
1067 1149
1068nonisolated private func renderOrgListItemBody( 1150nonisolated private func renderOrgListItemBody(
1069 _ lines: [String], 1151 _ lines: [String],
1070 imageURLResolver: ((String) -> String?)? = nil 1152 imageURLResolver: ((String) -> String?)? = nil,
1153 linkURLResolver: ((String) -> String?)? = nil
1071) -> String { 1154) -> String {
1072 guard let firstLine = lines.first else { return "" } 1155 guard let firstLine = lines.first else { return "" }
1073 1156
@@ -1089,17 +1172,18 @@ nonisolated private func renderOrgListItemBody(
1089 1172
1090 var html = renderTaskListItem( 1173 var html = renderTaskListItem(
1091 contentLines.joined(separator: " "), 1174 contentLines.joined(separator: " "),
1092 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } 1175 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
1093 ) 1176 )
1094 if !nestedLines.isEmpty { 1177 if !nestedLines.isEmpty {
1095 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver) 1178 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1096 } 1179 }
1097 return html 1180 return html
1098} 1181}
1099 1182
1100nonisolated private func renderNestedOrgListHTML( 1183nonisolated private func renderNestedOrgListHTML(
1101 _ lines: [String], 1184 _ lines: [String],
1102 imageURLResolver: ((String) -> String?)? = nil 1185 imageURLResolver: ((String) -> String?)? = nil,
1186 linkURLResolver: ((String) -> String?)? = nil
1103) -> String { 1187) -> String {
1104 var html = "" 1188 var html = ""
1105 var listType: OrgListType? 1189 var listType: OrgListType?
@@ -1107,7 +1191,7 @@ nonisolated private func renderNestedOrgListHTML(
1107 1191
1108 func flushNestedItem() { 1192 func flushNestedItem() {
1109 guard !currentItemLines.isEmpty else { return } 1193 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"
1111 currentItemLines = [] 1195 currentItemLines = []
1112 } 1196 }
1113 1197
@@ -1160,7 +1244,8 @@ nonisolated private func renderNestedOrgListHTML(
1160nonisolated private func protectOrgLinks( 1244nonisolated private func protectOrgLinks(
1161 in text: String, 1245 in text: String,
1162 protectedFragments: inout [String: String], 1246 protectedFragments: inout [String: String],
1163 imageURLResolver: ((String) -> String?)? = nil 1247 imageURLResolver: ((String) -> String?)? = nil,
1248 linkURLResolver: ((String) -> String?)? = nil
1164) -> String { 1249) -> String {
1165 var result = text 1250 var result = text
1166 1251
@@ -1172,7 +1257,8 @@ nonisolated private func protectOrgLinks(
1172 protectedFragments[token] = renderOrgLink( 1257 protectedFragments[token] = renderOrgLink(
1173 destination: parsed.destination, 1258 destination: parsed.destination,
1174 label: parsed.label, 1259 label: parsed.label,
1175 imageURLResolver: imageURLResolver 1260 imageURLResolver: imageURLResolver,
1261 linkURLResolver: linkURLResolver
1176 ) 1262 )
1177 result.replaceSubrange(parsed.range, with: token) 1263 result.replaceSubrange(parsed.range, with: token)
1178 } 1264 }
@@ -1221,12 +1307,14 @@ nonisolated private func parseOrgLink(
1221nonisolated private func renderOrgLink( 1307nonisolated private func renderOrgLink(
1222 destination: String, 1308 destination: String,
1223 label: String?, 1309 label: String?,
1224 imageURLResolver: ((String) -> String?)? = nil 1310 imageURLResolver: ((String) -> String?)? = nil,
1311 linkURLResolver: ((String) -> String?)? = nil
1225) -> String { 1312) -> String {
1226 if let label, label.hasPrefix("[["), label.hasSuffix("]]") { 1313 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1227 let source = String(label.dropFirst(2).dropLast(2)) 1314 let source = String(label.dropFirst(2).dropLast(2))
1228 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { 1315 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 {
1230 return imageHTML 1318 return imageHTML
1231 } 1319 }
1232 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# 1320 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
@@ -1241,11 +1329,14 @@ nonisolated private func renderOrgLink(
1241 return imageHTML 1329 return imageHTML
1242 } 1330 }
1243 1331
1244 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else { 1332 let resolvedDestination = linkURLResolver?(destination) ?? destination
1333 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1245 return label ?? destination 1334 return label ?? destination
1246 } 1335 }
1247 1336
1248 let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination 1337 let renderedLabel = label.map {
1338 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1339 } ?? destination
1249 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# 1340 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1250} 1341}
1251 1342
@@ -1461,6 +1552,28 @@ nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1461 .contains(where: { lowercased.hasSuffix($0) }) 1552 .contains(where: { lowercased.hasSuffix($0) })
1462} 1553}
1463 1554
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
1464nonisolated func resolveRepositoryAssetURL( 1577nonisolated func resolveRepositoryAssetURL(
1465 _ source: String, 1578 _ source: String,
1466 owner: String, 1579 owner: String,
HutchTests/ReadmeViewTests.swift +101
@@ -103,6 +103,44 @@ struct MarkdownRenderingTests {
103 #expect(!html.contains("javascript:")) 103 #expect(!html.contains("javascript:"))
104 } 104 }
105 105
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
106 @Test 144 @Test
107 func markdownImageRenders() { 145 func markdownImageRenders() {
108 let html = markdownToHTML("![logo](https://example.com/logo.png)") 146 let html = markdownToHTML("![logo](https://example.com/logo.png)")
@@ -349,3 +387,66 @@ struct RepositoryAssetURLTests {
349 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png") 387 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
350 } 388 }
351} 389}
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}