| @@ -7,6 +7,7 @@ struct ReadmeView: View { |
| 7 | 7 | |
| 8 | 8 | @Environment(\.colorScheme) private var colorScheme |
| 9 | 9 | @State private var isShowingRepositoryDetails = false |
| 10 | @State private var linkedFile: LinkedFileRequest? |
| 10 | 11 | |
| 11 | 12 | var body: some View { |
| 12 | 13 | ScrollView { |
| @@ -22,6 +23,14 @@ struct ReadmeView: View { |
| 22 | 23 | } |
| 23 | 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 | 34 | .task { |
| 26 | 35 | async let readme: () = viewModel.loadReadme() |
| 27 | 36 | async let commits: () = viewModel.loadCommits() |
| @@ -128,7 +137,12 @@ struct ReadmeView: View { |
| 128 | 137 | readmePath: viewModel.readmePath, |
| 129 | 138 | colorScheme: colorScheme, |
| 130 | 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 | 147 | } else if let error = viewModel.error, !viewModel.readmeLoaded { |
| 134 | 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 | 185 | private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { |
| 149 | 186 | switch content { |
| 150 | 187 | case .html(let html): |
| @@ -192,6 +229,7 @@ struct RenderedMarkupContentView: View { |
| 192 | 229 | let ownerCanonicalName: String |
| 193 | 230 | let repositoryName: String |
| 194 | 231 | var repositoryHost = "git.sr.ht" |
| 232 | var onInterceptURL: ((URL) -> Bool)? = nil |
| 195 | 233 | |
| 196 | 234 | @State private var renderedHTML: String? |
| 197 | 235 | |
| @@ -212,10 +250,10 @@ struct RenderedMarkupContentView: View { |
| 212 | 250 | Group { |
| 213 | 251 | switch content { |
| 214 | 252 | case .html(let html): |
| 215 | | HTMLWebView(html: html, colorScheme: colorScheme) |
| 253 | HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL) |
| 216 | 254 | case .markdown, .org: |
| 217 | 255 | if let renderedHTML { |
| 218 | | HTMLWebView(html: renderedHTML, colorScheme: colorScheme) |
| 256 | HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL) |
| 219 | 257 | } else { |
| 220 | 258 | SRHTLoadingStateView(message: "Preparing README…") |
| 221 | 259 | } |
| @@ -240,15 +278,27 @@ struct RenderedMarkupContentView: View { |
| 240 | 278 | return |
| 241 | 279 | } |
| 242 | 280 | 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 | ) |
| 252 | 302 | }.value |
| 253 | 303 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) |
| 254 | 304 | guard !Task.isCancelled else { return } |
| @@ -259,15 +309,27 @@ struct RenderedMarkupContentView: View { |
| 259 | 309 | return |
| 260 | 310 | } |
| 261 | 311 | 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 | ) |
| 271 | 333 | }.value |
| 272 | 334 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) |
| 273 | 335 | guard !Task.isCancelled else { return } |
| @@ -302,7 +364,11 @@ func clearWebContentRenderCaches() { |
| 302 | 364 | |
| 303 | 365 | // MARK: - Markdown to HTML |
| 304 | 366 | |
| 305 | | nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { |
| 367 | nonisolated func processInline( |
| 368 | _ text: String, |
| 369 | imageURLResolver: ((String) -> String?)? = nil, |
| 370 | linkURLResolver: ((String) -> String?)? = nil |
| 371 | ) -> String { |
| 306 | 372 | |
| 307 | 373 | var protectedFragments: [String: String] = [:] |
| 308 | 374 | var result = protectMatches( |
| @@ -330,7 +396,8 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St |
| 330 | 396 | result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in |
| 331 | 397 | let label = nsText.substring(with: match.range(at: 1)) |
| 332 | 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 | 401 | return label |
| 335 | 402 | } |
| 336 | 403 | return #"<a href="\#(sanitizedURL)">\#(label)</a>"# |
| @@ -387,7 +454,11 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St |
| 387 | 454 | |
| 388 | 455 | // MARK: - Org-mode to HTML |
| 389 | 456 | |
| 390 | | nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { |
| 457 | nonisolated func orgToHTML( |
| 458 | _ text: String, |
| 459 | imageURLResolver: ((String) -> String?)? = nil, |
| 460 | linkURLResolver: ((String) -> String?)? = nil |
| 461 | ) -> String { |
| 391 | 462 | let normalizedText = text |
| 392 | 463 | .replacingOccurrences(of: "\r\n", with: "\n") |
| 393 | 464 | .replacingOccurrences(of: "\r", with: "\n") |
| @@ -445,7 +516,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 445 | 516 | func closePendingBlockWrapper() { |
| 446 | 517 | guard isWrappingBlockFigure else { return } |
| 447 | 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 | 521 | html += "</figure>\n" |
| 451 | 522 | activeBlockCaption = nil |
| @@ -457,7 +528,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 457 | 528 | let normalizedParagraph = paragraph |
| 458 | 529 | .map { $0.trimmingCharacters(in: .whitespaces) } |
| 459 | 530 | .joined(separator: " ") |
| 460 | | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n" |
| 531 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n" |
| 461 | 532 | paragraph = [] |
| 462 | 533 | } |
| 463 | 534 | } |
| @@ -466,7 +537,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 466 | 537 | guard !currentListItemLines.isEmpty else { return } |
| 467 | 538 | html += "<li>" + renderOrgListItemBody( |
| 468 | 539 | currentListItemLines, |
| 469 | | imageURLResolver: imageURLResolver |
| 540 | imageURLResolver: imageURLResolver, |
| 541 | linkURLResolver: linkURLResolver |
| 470 | 542 | ) + "</li>\n" |
| 471 | 543 | currentListItemLines = [] |
| 472 | 544 | } |
| @@ -489,7 +561,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 489 | 561 | beginPendingBlockWrapperIfNeeded() |
| 490 | 562 | html += renderHTMLTable( |
| 491 | 563 | rows: tableRows, |
| 492 | | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } |
| 564 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } |
| 493 | 565 | ) |
| 494 | 566 | closePendingBlockWrapper() |
| 495 | 567 | tableRows = [] |
| @@ -500,7 +572,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 500 | 572 | html += "<dl class=\"org-properties\">\n" |
| 501 | 573 | for (key, value) in propertyRows { |
| 502 | 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 | 577 | html += "</dl>\n" |
| 506 | 578 | propertyRows = [] |
| @@ -542,7 +614,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 542 | 614 | func closeVerseBlock() { |
| 543 | 615 | if inVerseBlock { |
| 544 | 616 | let content = verseLines |
| 545 | | .map { processOrgInline($0, imageURLResolver: imageURLResolver) } |
| 617 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } |
| 546 | 618 | .joined(separator: "\n") |
| 547 | 619 | html += #"<blockquote class="org-verse">"# + "\n" |
| 548 | 620 | html += content + "\n" |
| @@ -738,7 +810,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 738 | 810 | closeQuoteBlock() |
| 739 | 811 | flushBlockState() |
| 740 | 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 | 814 | html += "<h\(level)>" + content + "</h\(level)>\n" |
| 743 | 815 | continue |
| 744 | 816 | } |
| @@ -803,7 +875,11 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String |
| 803 | 875 | return html |
| 804 | 876 | } |
| 805 | 877 | |
| 806 | | nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { |
| 878 | nonisolated private func processOrgInline( |
| 879 | _ text: String, |
| 880 | imageURLResolver: ((String) -> String?)? = nil, |
| 881 | linkURLResolver: ((String) -> String?)? = nil |
| 882 | ) -> String { |
| 807 | 883 | var result = escapeHTML(text) |
| 808 | 884 | var protectedFragments: [String: String] = [:] |
| 809 | 885 | |
| @@ -821,13 +897,19 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St |
| 821 | 897 | ) else { |
| 822 | 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 | 902 | return imageHTML |
| 826 | 903 | } |
| 827 | 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 | 913 | result = protectMatches( |
| 832 | 914 | in: result, |
| 833 | 915 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, |
| @@ -1067,7 +1149,8 @@ nonisolated private func orderedListItem(in line: String) -> String? { |
| 1067 | 1149 | |
| 1068 | 1150 | nonisolated private func renderOrgListItemBody( |
| 1069 | 1151 | _ lines: [String], |
| 1070 | | imageURLResolver: ((String) -> String?)? = nil |
| 1152 | imageURLResolver: ((String) -> String?)? = nil, |
| 1153 | linkURLResolver: ((String) -> String?)? = nil |
| 1071 | 1154 | ) -> String { |
| 1072 | 1155 | guard let firstLine = lines.first else { return "" } |
| 1073 | 1156 | |
| @@ -1089,17 +1172,18 @@ nonisolated private func renderOrgListItemBody( |
| 1089 | 1172 | |
| 1090 | 1173 | var html = renderTaskListItem( |
| 1091 | 1174 | contentLines.joined(separator: " "), |
| 1092 | | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } |
| 1175 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } |
| 1093 | 1176 | ) |
| 1094 | 1177 | if !nestedLines.isEmpty { |
| 1095 | | html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver) |
| 1178 | html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) |
| 1096 | 1179 | } |
| 1097 | 1180 | return html |
| 1098 | 1181 | } |
| 1099 | 1182 | |
| 1100 | 1183 | nonisolated private func renderNestedOrgListHTML( |
| 1101 | 1184 | _ lines: [String], |
| 1102 | | imageURLResolver: ((String) -> String?)? = nil |
| 1185 | imageURLResolver: ((String) -> String?)? = nil, |
| 1186 | linkURLResolver: ((String) -> String?)? = nil |
| 1103 | 1187 | ) -> String { |
| 1104 | 1188 | var html = "" |
| 1105 | 1189 | var listType: OrgListType? |
| @@ -1107,7 +1191,7 @@ nonisolated private func renderNestedOrgListHTML( |
| 1107 | 1191 | |
| 1108 | 1192 | func flushNestedItem() { |
| 1109 | 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 | 1195 | currentItemLines = [] |
| 1112 | 1196 | } |
| 1113 | 1197 | |
| @@ -1160,7 +1244,8 @@ nonisolated private func renderNestedOrgListHTML( |
| 1160 | 1244 | nonisolated private func protectOrgLinks( |
| 1161 | 1245 | in text: String, |
| 1162 | 1246 | protectedFragments: inout [String: String], |
| 1163 | | imageURLResolver: ((String) -> String?)? = nil |
| 1247 | imageURLResolver: ((String) -> String?)? = nil, |
| 1248 | linkURLResolver: ((String) -> String?)? = nil |
| 1164 | 1249 | ) -> String { |
| 1165 | 1250 | var result = text |
| 1166 | 1251 | |
| @@ -1172,7 +1257,8 @@ nonisolated private func protectOrgLinks( |
| 1172 | 1257 | protectedFragments[token] = renderOrgLink( |
| 1173 | 1258 | destination: parsed.destination, |
| 1174 | 1259 | label: parsed.label, |
| 1175 | | imageURLResolver: imageURLResolver |
| 1260 | imageURLResolver: imageURLResolver, |
| 1261 | linkURLResolver: linkURLResolver |
| 1176 | 1262 | ) |
| 1177 | 1263 | result.replaceSubrange(parsed.range, with: token) |
| 1178 | 1264 | } |
| @@ -1221,12 +1307,14 @@ nonisolated private func parseOrgLink( |
| 1221 | 1307 | nonisolated private func renderOrgLink( |
| 1222 | 1308 | destination: String, |
| 1223 | 1309 | label: String?, |
| 1224 | | imageURLResolver: ((String) -> String?)? = nil |
| 1310 | imageURLResolver: ((String) -> String?)? = nil, |
| 1311 | linkURLResolver: ((String) -> String?)? = nil |
| 1225 | 1312 | ) -> String { |
| 1226 | 1313 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { |
| 1227 | 1314 | let source = String(label.dropFirst(2).dropLast(2)) |
| 1228 | 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 | 1318 | return imageHTML |
| 1231 | 1319 | } |
| 1232 | 1320 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# |
| @@ -1241,11 +1329,14 @@ nonisolated private func renderOrgLink( |
| 1241 | 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 | 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 | 1340 | return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# |
| 1250 | 1341 | } |
| 1251 | 1342 | |
| @@ -1461,6 +1552,28 @@ nonisolated private func isRenderableImageSource(_ source: String) -> Bool { |
| 1461 | 1552 | .contains(where: { lowercased.hasSuffix($0) }) |
| 1462 | 1553 | } |
| 1463 | 1554 | |
| 1555 | nonisolated 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 | |
| 1464 | 1577 | nonisolated func resolveRepositoryAssetURL( |
| 1465 | 1578 | _ source: String, |
| 1466 | 1579 | owner: String, |