| @@ -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 | |
| 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 | 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 | |
| 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 | 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 | |
| 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 | 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 | |
| 1068 | nonisolated private func renderOrgListItemBody( |
1150 | nonisolated 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 | |
| 1100 | nonisolated private func renderNestedOrgListHTML( |
1183 | nonisolated 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( |
| 1160 | nonisolated private func protectOrgLinks( |
1244 | nonisolated 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( |
| 1221 | nonisolated private func renderOrgLink( |
1307 | nonisolated 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 | |
| |
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 | nonisolated func resolveRepositoryAssetURL( |
1577 | nonisolated func resolveRepositoryAssetURL( |
| 1465 | _ source: String, |
1578 | _ source: String, |
| 1466 | owner: String, |
1579 | owner: String, |