krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.0: Hutch/Views/Repositories/ReadmeView.swift · raw
1import SwiftUI
2import WebKit
3
4struct ReadmeView: View {
5 let viewModel: RepositoryDetailViewModel
6
7 @Environment(\.colorScheme) private var colorScheme
8 @State private var isShowingRepositoryDetails = false
9
10 var body: some View {
11 ScrollView {
12 VStack(alignment: .leading, spacing: 16) {
13 headerSection
14 metadataSection
15 repositoryDetailsSection
16 latestChangeSection
17 readmeSection
18 }
19 .padding()
20 }
21 .task {
22 async let readme: () = viewModel.loadReadme()
23 async let commits: () = viewModel.loadCommits()
24 async let refs: () = viewModel.loadReferences()
25 _ = await (readme, commits, refs)
26 }
27 .navigationDestination(for: CommitSummary.self) { commit in
28 CommitDetailView(
29 commitSummary: commit,
30 repository: viewModel.repository
31 )
32 }
33 }
34
35 @ViewBuilder
36 private var headerSection: some View {
37 VStack(alignment: .leading, spacing: 6) {
38 Text(viewModel.repository.owner.canonicalName)
39 .font(.subheadline)
40 .foregroundStyle(.secondary)
41 Text(viewModel.repository.name)
42 .font(.largeTitle.weight(.semibold))
43 if let description = viewModel.repository.description, !description.isEmpty {
44 Text(description)
45 .font(.body)
46 }
47 }
48 }
49
50 @ViewBuilder
51 private var metadataSection: some View {
52 VStack(alignment: .leading, spacing: 10) {
53 SummaryMetadataRow(
54 icon: "arrow.triangle.branch",
55 title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
56 )
57
58 if let readmePath = viewModel.readmePath {
59 SummaryMetadataRow(
60 icon: "doc.text",
61 title: readmePath
62 )
63 }
64 }
65 }
66
67 private var repositoryDetailsSection: some View {
68 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
69 VStack(alignment: .leading, spacing: 12) {
70 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
71 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
72 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
73 SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
74 }
75 .padding(.top, 8)
76 } label: {
77 Text("Repository Details")
78 .font(.subheadline.weight(.medium))
79 }
80 }
81
82 @ViewBuilder
83 private var latestChangeSection: some View {
84 VStack(alignment: .leading, spacing: 8) {
85 if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
86 SRHTLoadingStateView(message: "Loading latest change…")
87 .frame(maxWidth: .infinity)
88 } else if let commit = viewModel.commits.first {
89 NavigationLink(value: commit) {
90 SummaryMetadataRow(
91 icon: "arrow.trianglehead.clockwise",
92 title: commit.title,
93 subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
94 )
95 .contentShape(Rectangle())
96 }
97 .buttonStyle(.plain)
98 } else if let error = viewModel.error, viewModel.commits.isEmpty {
99 SRHTErrorStateView(
100 title: "Couldn't Load Latest Change",
101 message: error,
102 retryAction: { await viewModel.loadCommits() }
103 )
104 } else {
105 ContentUnavailableView(
106 "No Recent Commits",
107 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
108 description: Text("This repository does not have any commit history yet.")
109 )
110 }
111 }
112 }
113
114 @ViewBuilder
115 private var readmeSection: some View {
116 if viewModel.isLoadingReadme {
117 SRHTLoadingStateView(message: "Loading README…")
118 } else if let content = viewModel.readmeContent {
119 RenderedMarkupContentView(
120 content: sharedReadmeContent(from: content),
121 readmePath: viewModel.readmePath,
122 colorScheme: colorScheme,
123 ownerCanonicalName: viewModel.repository.owner.canonicalName,
124 repositoryName: viewModel.repository.name
125 )
126 } else if let error = viewModel.error, !viewModel.readmeLoaded {
127 SRHTErrorStateView(
128 title: "Couldn't Load README",
129 message: error,
130 retryAction: { await viewModel.loadReadme() }
131 )
132 } else {
133 ContentUnavailableView(
134 "No README",
135 systemImage: "doc.text",
136 description: Text("This repository does not have a README file.")
137 )
138 }
139 }
140
141 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
142 switch content {
143 case .html(let html):
144 .html(html)
145 case .markdown(let text):
146 .markdown(text)
147 case .org(let text):
148 .org(text)
149 case .plainText(let text):
150 .plainText(text)
151 }
152 }
153}
154
155enum RenderedMarkupContent: Sendable {
156 case html(String)
157 case markdown(String)
158 case org(String)
159 case plainText(String)
160}
161
162struct RenderedMarkupContentView: View {
163 let content: RenderedMarkupContent
164 let readmePath: String?
165 let colorScheme: ColorScheme
166 let ownerCanonicalName: String
167 let repositoryName: String
168 var repositoryHost = "git.sr.ht"
169
170 @State private var renderedHTML: String?
171
172 private var cacheKey: String {
173 switch content {
174 case .html(let html):
175 "html:\(readmePath ?? "custom"):\(html)"
176 case .markdown(let text):
177 "markdown:\(readmePath ?? ""):\(text)"
178 case .org(let text):
179 "org:\(readmePath ?? ""):\(text)"
180 case .plainText(let text):
181 "plain:\(readmePath ?? ""):\(text)"
182 }
183 }
184
185 var body: some View {
186 Group {
187 switch content {
188 case .html(let html):
189 HTMLWebView(html: html, colorScheme: colorScheme)
190 case .markdown, .org:
191 if let renderedHTML {
192 HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
193 } else {
194 SRHTLoadingStateView(message: "Preparing README…")
195 }
196 case .plainText(let text):
197 Text(text)
198 .font(.system(.body, design: .monospaced))
199 .frame(maxWidth: .infinity, alignment: .leading)
200 }
201 }
202 .task(id: cacheKey) {
203 await prepareHTMLIfNeeded()
204 }
205 }
206
207 private func prepareHTMLIfNeeded() async {
208 switch content {
209 case .html, .plainText:
210 renderedHTML = nil
211 case .markdown(let text):
212 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
213 renderedHTML = cached
214 return
215 }
216 let html = await Task.detached(priority: .userInitiated) {
217 markdownToHTML(text) { source in
218 resolveRepositoryAssetURL(
219 source,
220 owner: ownerCanonicalName,
221 repositoryName: repositoryName,
222 readmePath: readmePath
223 )?
224 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
225 }
226 }.value
227 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
228 guard !Task.isCancelled else { return }
229 renderedHTML = html
230 case .org(let text):
231 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
232 renderedHTML = cached
233 return
234 }
235 let html = await Task.detached(priority: .userInitiated) {
236 orgToHTML(text) { source in
237 resolveRepositoryAssetURL(
238 source,
239 owner: ownerCanonicalName,
240 repositoryName: repositoryName,
241 readmePath: readmePath
242 )?
243 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
244 }
245 }.value
246 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
247 guard !Task.isCancelled else { return }
248 renderedHTML = html
249 }
250 }
251}
252
253private final class RenderedReadmeHTMLCache: @unchecked Sendable {
254 static let shared = RenderedReadmeHTMLCache()
255
256 private let storage = NSCache<NSString, NSString>()
257
258 func html(forKey key: String) -> String? {
259 storage.object(forKey: key as NSString) as String?
260 }
261
262 func setHTML(_ html: String, forKey key: String) {
263 storage.setObject(html as NSString, forKey: key as NSString)
264 }
265
266 func removeAll() {
267 storage.removeAllObjects()
268 }
269}
270
271@MainActor
272func clearWebContentRenderCaches() {
273 RenderedReadmeHTMLCache.shared.removeAll()
274 HTMLWebViewCoordinator.heightCache.removeAllObjects()
275}
276
277// MARK: - Markdown to HTML
278
279nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280
281 var protectedFragments: [String: String] = [:]
282 var result = protectMatches(
283 in: text,
284 pattern: #"</?[A-Za-z][^>]*?>"#,
285 protectedFragments: &protectedFragments
286 ) { match, nsText in
287 let rawTag = nsText.substring(with: match.range)
288 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
289 }
290
291 result = escapeHTML(result)
292
293 // Images: 
294 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
295 let alt = nsText.substring(with: match.range(at: 1))
296 let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
297 let resolvedSource = imageURLResolver?(source) ?? source
298 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
299 return escapeHTML(alt)
300 }
301 return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
302 }
303 // Links: [text](url)
304 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
305 let label = nsText.substring(with: match.range(at: 1))
306 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
307 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
308 return label
309 }
310 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
311 }
312 // Plain email autolinks
313 result = replaceMatches(
314 in: result,
315 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
316 ) { match, nsText in
317 guard !isInsideHTMLTag(nsText, range: match.range) else {
318 return nsText.substring(with: match.range)
319 }
320 let email = nsText.substring(with: match.range(at: 1))
321 let href = escapeHTMLAttribute("mailto:\(email)")
322 return #"<a href="\#(href)">\#(email)</a>"#
323 }
324 // Strikethrough: ~~text~~
325 result = result.replacingOccurrences(
326 of: #"~~(.+?)~~"#,
327 with: "<del>$1</del>",
328 options: .regularExpression
329 )
330 // Bold: **text**
331 result = result.replacingOccurrences(
332 of: #"\*\*(.+?)\*\*"#,
333 with: "<strong>$1</strong>",
334 options: .regularExpression
335 )
336 // Italic: *text*
337 result = result.replacingOccurrences(
338 of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
339 with: "<em>$1</em>",
340 options: .regularExpression
341 )
342 // Italic: _text_
343 result = result.replacingOccurrences(
344 of: #"(?<!\w)_(.+?)_(?!\w)"#,
345 with: "<em>$1</em>",
346 options: .regularExpression
347 )
348 // Inline code: `text`
349 result = result.replacingOccurrences(
350 of: #"`([^`]+)`"#,
351 with: "<code>$1</code>",
352 options: .regularExpression
353 )
354
355 for (token, fragment) in protectedFragments {
356 result = result.replacingOccurrences(of: token, with: fragment)
357 }
358
359 return result
360}
361
362// MARK: - Org-mode to HTML
363
364nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
365 let normalizedText = text
366 .replacingOccurrences(of: "\r\n", with: "\n")
367 .replacingOccurrences(of: "\r", with: "\n")
368 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
369 var title: String?
370 var author: String?
371 var date: String?
372 let lines = rawLines.filter { line in
373 let trimmed = line.trimmingCharacters(in: .whitespaces)
374 guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else {
375 return true
376 }
377 let keyword = String(keywordMatch.1).lowercased()
378 let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
379 switch keyword {
380 case "title":
381 title = value
382 return false
383 case "author":
384 author = value
385 return false
386 case "date":
387 date = value
388 return false
389 default:
390 return true
391 }
392 }
393 var html = ""
394 var listType: OrgListType?
395 var inQuoteBlock = false
396 var inPropertyDrawer = false
397 var srcLanguage: String?
398 var inExampleBlock = false
399 var inCenterBlock = false
400 var currentListItemLines: [String] = []
401 var paragraph: [String] = []
402 var tableRows: [[String]] = []
403 var propertyRows: [(String, String)] = []
404
405 func flushParagraph() {
406 if !paragraph.isEmpty {
407 let normalizedParagraph = paragraph
408 .map { $0.trimmingCharacters(in: .whitespaces) }
409 .joined(separator: " ")
410 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
411 paragraph = []
412 }
413 }
414
415 func flushListItem() {
416 guard !currentListItemLines.isEmpty else { return }
417 let content = currentListItemLines
418 .map { $0.trimmingCharacters(in: .whitespaces) }
419 .joined(separator: " ")
420 html += "<li>" + renderTaskListItem(
421 content,
422 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
423 ) + "</li>\n"
424 currentListItemLines = []
425 }
426
427 func closeList() {
428 flushListItem()
429 switch listType {
430 case .unordered:
431 html += "</ul>\n"
432 case .ordered:
433 html += "</ol>\n"
434 case nil:
435 break
436 }
437 listType = nil
438 }
439
440 func flushTable() {
441 guard !tableRows.isEmpty else { return }
442 html += renderHTMLTable(
443 rows: tableRows,
444 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
445 )
446 tableRows = []
447 }
448
449 func flushPropertyDrawer() {
450 guard !propertyRows.isEmpty else { return }
451 html += "<dl class=\"org-properties\">\n"
452 for (key, value) in propertyRows {
453 html += "<dt>" + escapeHTML(key) + "</dt>"
454 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
455 }
456 html += "</dl>\n"
457 propertyRows = []
458 }
459
460 func closeQuoteBlock() {
461 if inQuoteBlock {
462 flushParagraph()
463 html += "</blockquote>\n"
464 inQuoteBlock = false
465 }
466 }
467
468 func closeSourceBlock() {
469 if srcLanguage != nil {
470 html += "</code></pre>\n"
471 srcLanguage = nil
472 }
473 }
474
475 func closeExampleBlock() {
476 if inExampleBlock {
477 html += "</code></pre>\n"
478 inExampleBlock = false
479 }
480 }
481
482 func closeCenterBlock() {
483 if inCenterBlock {
484 flushParagraph()
485 html += "</div>\n"
486 inCenterBlock = false
487 }
488 }
489
490 func flushBlockState() {
491 flushParagraph()
492 closeList()
493 flushTable()
494 flushPropertyDrawer()
495 }
496
497 if title != nil || author != nil || date != nil {
498 html += "<div class=\"org-metadata\">\n"
499 if let title {
500 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
501 }
502 if let author {
503 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
504 }
505 if let date {
506 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
507 }
508 html += "</div>\n"
509 }
510
511 for line in lines {
512 let trimmed = line.trimmingCharacters(in: .whitespaces)
513
514 if srcLanguage != nil {
515 if trimmed.lowercased() == "#+end_src" {
516 closeSourceBlock()
517 } else {
518 html += escapeHTML(line) + "\n"
519 }
520 continue
521 }
522
523 if inExampleBlock {
524 if trimmed.lowercased() == "#+end_example" {
525 closeExampleBlock()
526 } else {
527 html += escapeHTML(line) + "\n"
528 }
529 continue
530 }
531
532 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
533 closeQuoteBlock()
534 continue
535 }
536
537 if inCenterBlock {
538 if trimmed.lowercased() == "#+end_center" {
539 closeCenterBlock()
540 } else if trimmed.isEmpty {
541 flushParagraph()
542 } else {
543 paragraph.append(line)
544 }
545 continue
546 }
547
548 if trimmed == "#" || trimmed.hasPrefix("# ") {
549 continue
550 }
551
552 if trimmed.lowercased().hasPrefix("#+begin_src") {
553 closeQuoteBlock()
554 flushBlockState()
555 let language = trimmed
556 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
557 .dropFirst()
558 .first
559 .map(String.init)?
560 .trimmingCharacters(in: .whitespacesAndNewlines)
561 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
562 html += "<pre><code\(classAttribute)>"
563 srcLanguage = language ?? ""
564 continue
565 }
566
567 if trimmed.lowercased() == "#+begin_example" {
568 closeQuoteBlock()
569 flushBlockState()
570 html += "<pre><code>"
571 inExampleBlock = true
572 continue
573 }
574
575 if trimmed.lowercased() == "#+begin_quote" {
576 flushBlockState()
577 html += "<blockquote>\n"
578 inQuoteBlock = true
579 continue
580 }
581
582 if trimmed.lowercased() == "#+begin_center" {
583 closeQuoteBlock()
584 flushBlockState()
585 html += "<div style=\"text-align:center\">\n"
586 inCenterBlock = true
587 continue
588 }
589
590 if trimmed == ":PROPERTIES:" {
591 closeQuoteBlock()
592 flushBlockState()
593 inPropertyDrawer = true
594 continue
595 }
596
597 if trimmed == ":END:", inPropertyDrawer {
598 flushPropertyDrawer()
599 inPropertyDrawer = false
600 continue
601 }
602
603 if inPropertyDrawer,
604 trimmed.hasPrefix(":"),
605 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
606 let keyStart = trimmed.index(after: trimmed.startIndex)
607 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
608 let valueStart = trimmed.index(after: secondColonIndex)
609 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
610 if !key.isEmpty {
611 propertyRows.append((key, value))
612 continue
613 }
614 }
615
616 if isTableLine(trimmed) {
617 closeQuoteBlock()
618 flushParagraph()
619 closeList()
620 tableRows.append(parseTableRow(trimmed))
621 continue
622 } else {
623 flushTable()
624 }
625
626 if isOrgHorizontalRule(trimmed) {
627 closeQuoteBlock()
628 flushBlockState()
629 html += "<hr>\n"
630 continue
631 }
632
633 // Org headings: * heading, ** heading, *** heading
634 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
635 closeQuoteBlock()
636 flushBlockState()
637 let level = match.1.count
638 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
639 html += "<h\(level)>" + content + "</h\(level)>\n"
640 continue
641 }
642
643 // List items: - item
644 if trimmed.hasPrefix("- ") {
645 flushParagraph()
646 flushPropertyDrawer()
647 if listType != .unordered {
648 closeList()
649 html += "<ul>\n"
650 listType = .unordered
651 }
652 flushListItem()
653 currentListItemLines = [String(trimmed.dropFirst(2))]
654 continue
655 }
656
657 if let orderedItem = orderedListItem(in: trimmed) {
658 flushParagraph()
659 flushPropertyDrawer()
660 if listType != .ordered {
661 closeList()
662 html += "<ol>\n"
663 listType = .ordered
664 }
665 flushListItem()
666 currentListItemLines = [orderedItem]
667 continue
668 }
669
670 if listType != nil && isIndentedContinuationLine(line) {
671 currentListItemLines.append(trimmed)
672 continue
673 }
674
675 // Blank line
676 if trimmed.isEmpty {
677 if inQuoteBlock {
678 flushParagraph()
679 } else {
680 flushBlockState()
681 }
682 continue
683 }
684
685 // Regular text
686 paragraph.append(line)
687 }
688
689 closeSourceBlock()
690 closeExampleBlock()
691 closeCenterBlock()
692 closeQuoteBlock()
693 flushBlockState()
694
695 return html
696}
697
698nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
699 var result = escapeHTML(text)
700 var protectedFragments: [String: String] = [:]
701
702 result = protectMatches(
703 in: result,
704 pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
705 protectedFragments: &protectedFragments
706 ) { match, nsText in
707 let url = nsText.substring(with: match.range(at: 1))
708 let label = nsText.substring(with: match.range(at: 2))
709 if let imageHTML = makeOrgImageHTML(
710 source: url,
711 alt: label,
712 imageURLResolver: imageURLResolver
713 ) {
714 return imageHTML
715 }
716 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
717 return label
718 }
719 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
720 }
721 result = protectMatches(
722 in: result,
723 pattern: #"\[\[([^\]]+)\]\]"#,
724 protectedFragments: &protectedFragments
725 ) { match, nsText in
726 let url = nsText.substring(with: match.range(at: 1))
727 if let imageHTML = makeOrgImageHTML(
728 source: url,
729 alt: nil,
730 imageURLResolver: imageURLResolver
731 ) {
732 return imageHTML
733 }
734 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
735 return url
736 }
737 return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
738 }
739 result = protectMatches(
740 in: result,
741 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
742 protectedFragments: &protectedFragments
743 ) { match, nsText in
744 let tildeRange = match.range(at: 1)
745 let equalsRange = match.range(at: 2)
746 let codeText: String
747 if tildeRange.location != NSNotFound {
748 codeText = nsText.substring(with: tildeRange)
749 } else {
750 codeText = nsText.substring(with: equalsRange)
751 }
752 return "<code>\(codeText)</code>"
753 }
754 result = protectMatches(
755 in: result,
756 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
757 protectedFragments: &protectedFragments
758 ) { match, nsText in
759 let value = nsText.substring(with: match.range(at: 1))
760 return "<del>\(value)</del>"
761 }
762 result = protectMatches(
763 in: result,
764 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
765 protectedFragments: &protectedFragments
766 ) { match, nsText in
767 let value = nsText.substring(with: match.range(at: 1))
768 return "<u>\(value)</u>"
769 }
770
771 // Bold: *text*
772 result = result.replacingOccurrences(
773 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
774 with: "<strong>$1</strong>",
775 options: .regularExpression
776 )
777 // Italic: /text/
778 result = result.replacingOccurrences(
779 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
780 with: "<em>$1</em>",
781 options: .regularExpression
782 )
783 result = replaceMatches(
784 in: result,
785 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
786 ) { match, nsText in
787 guard !isInsideHTMLTag(nsText, range: match.range) else {
788 return nsText.substring(with: match.range)
789 }
790 let email = nsText.substring(with: match.range(at: 1))
791 let href = escapeHTMLAttribute("mailto:\(email)")
792 return #"<a href="\#(href)">\#(email)</a>"#
793 }
794
795 for (token, fragment) in protectedFragments {
796 result = result.replacingOccurrences(of: token, with: fragment)
797 }
798
799 return result
800}
801
802// MARK: - HTML Escaping
803
804nonisolated func escapeHTML(_ text: String) -> String {
805 text.replacingOccurrences(of: "&", with: "&")
806 .replacingOccurrences(of: "<", with: "<")
807 .replacingOccurrences(of: ">", with: ">")
808 .replacingOccurrences(of: "\"", with: """)
809}
810
811nonisolated func escapeHTMLAttribute(_ text: String) -> String {
812 escapeHTML(text).replacingOccurrences(of: "'", with: "'")
813}
814
815nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
816 sanitizeReadmeURLString(
817 rawURL,
818 allowedSchemes: ["http", "https", "mailto"],
819 allowsFragmentOnly: true
820 )
821}
822
823nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
824 sanitizeReadmeURLString(
825 rawURL,
826 allowedSchemes: ["http", "https"],
827 allowsFragmentOnly: false
828 )
829}
830
831nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
832 guard let scheme = url.scheme?.lowercased() else {
833 return false
834 }
835 if scheme == "about" || scheme == "data" {
836 return true
837 }
838 guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
839 return false
840 }
841 return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
842}
843
844nonisolated private func sanitizeReadmeURLString(
845 _ rawURL: String,
846 allowedSchemes: Set<String>,
847 allowsFragmentOnly: Bool
848) -> String? {
849 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
850 guard !trimmedURL.isEmpty else { return nil }
851
852 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
853 return escapeHTMLAttribute(trimmedURL)
854 }
855
856 guard let components = URLComponents(string: trimmedURL),
857 let scheme = components.scheme?.lowercased(),
858 allowedSchemes.contains(scheme),
859 let sanitizedURL = components.url?.absoluteString else {
860 return nil
861 }
862
863 return escapeHTMLAttribute(sanitizedURL)
864}
865
866nonisolated private func isTableLine(_ line: String) -> Bool {
867 line.hasPrefix("|") && line.hasSuffix("|")
868}
869
870nonisolated private func parseTableRow(_ line: String) -> [String] {
871 line
872 .split(separator: "|", omittingEmptySubsequences: false)
873 .dropFirst()
874 .dropLast()
875 .map { String($0).trimmingCharacters(in: .whitespaces) }
876}
877
878nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
879 let trimmed = cell.trimmingCharacters(in: .whitespaces)
880 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
881}
882
883nonisolated private func renderHTMLTable(
884 rows: [[String]],
885 inlineRenderer: (String) -> String
886) -> String {
887 guard !rows.isEmpty else { return "" }
888 let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell)
889 let headerRow = rows.first ?? []
890 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
891 var html = "<table>\n"
892
893 if hasHeaderSeparator {
894 html += "<thead><tr>"
895 for cell in headerRow {
896 html += "<th>" + inlineRenderer(cell) + "</th>"
897 }
898 html += "</tr></thead>\n"
899 }
900
901 html += "<tbody>\n"
902 for row in bodyRows {
903 html += "<tr>"
904 for cell in row {
905 html += "<td>" + inlineRenderer(cell) + "</td>"
906 }
907 html += "</tr>\n"
908 }
909 html += "</tbody>\n"
910 html += "</table>\n"
911 return html
912}
913
914private enum OrgListType: Equatable {
915 case unordered
916 case ordered
917}
918
919nonisolated private func orderedListItem(in line: String) -> String? {
920 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
921 return String(match.2)
922}
923
924
925nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
926 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
927}
928
929nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
930 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
931 let range = NSRange(location: 0, length: (text as NSString).length)
932 return regex.firstMatch(in: text, range: range) != nil
933}
934
935nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
936 guard range.location != NSNotFound else { return false }
937 let prefix = text.substring(to: range.location)
938 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
939 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
940 return lastOpen > lastClose
941}
942
943nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
944 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
945 guard let first = line.first else { return false }
946 return first == " " || first == "\t"
947}
948
949
950nonisolated func decodeHTMLEntities(_ text: String) -> String {
951 text
952 .replacingOccurrences(of: "&", with: "&")
953 .replacingOccurrences(of: """, with: "\"")
954 .replacingOccurrences(of: "'", with: "'")
955 .replacingOccurrences(of: "<", with: "<")
956 .replacingOccurrences(of: ">", with: ">")
957}
958
959nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
960 var protectedFragments: [String: String] = [:]
961 var foundUnsafeMarkup = false
962 let protected = protectMatches(
963 in: rawHTML,
964 pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
965 protectedFragments: &protectedFragments
966 ) { match, nsText in
967 let rawTag = nsText.substring(with: match.range)
968 guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
969 foundUnsafeMarkup = true
970 return ""
971 }
972 return sanitizedTag
973 }
974
975 guard !foundUnsafeMarkup else { return nil }
976
977 var sanitized = escapeHTML(protected)
978 sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
979 let token = nsText.substring(with: match.range)
980 return protectedFragments[token] ?? ""
981 }
982
983 return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
984}
985
986nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
987 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
988 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
989 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
990
991 let selfClosing = trimmed.hasSuffix("/>")
992 let contentStart = trimmed.index(after: trimmed.startIndex)
993 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
994 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
995 let isClosing = inner.hasPrefix("/")
996 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
997 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
998 guard let rawName = parts.first else { return nil }
999 let tagName = rawName.lowercased()
1000 let allowedTags: Set<String> = [
1001 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1002 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1003 "sup", "u", "ul"
1004 ]
1005 guard allowedTags.contains(tagName) else { return nil }
1006
1007 if isClosing {
1008 return "</\(tagName)>"
1009 }
1010
1011 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1012 let attributes = parseHTMLAttributes(attributePortion)
1013 var renderedAttributes: [String] = []
1014
1015 for (name, value) in attributes {
1016 switch (tagName, name.lowercased()) {
1017 case ("a", "href"):
1018 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1019 renderedAttributes.append(#"href="\#(sanitized)""#)
1020 }
1021 case ("img", "src"):
1022 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1023 renderedAttributes.append(#"src="\#(sanitized)""#)
1024 }
1025 case ("img", "alt"), (_, "title"), (_, "class"):
1026 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1027 default:
1028 continue
1029 }
1030 }
1031
1032 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1033 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1034 return "<\(tagName)\(attributeText)\(suffix)>"
1035}
1036
1037nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1038 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1039 return []
1040 }
1041 let nsText = text as NSString
1042 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1043 let name = nsText.substring(with: match.range(at: 1))
1044 let value = nsText.substring(with: match.range(at: 2))
1045 return (name, value)
1046 }
1047}
1048
1049nonisolated private func protectMatches(
1050 in text: String,
1051 pattern: String,
1052 protectedFragments: inout [String: String],
1053 transform: (NSTextCheckingResult, NSString) -> String
1054) -> String {
1055 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1056 var result = text
1057 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1058
1059 for match in matches.reversed() {
1060 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1061 let nsText = result as NSString
1062 protectedFragments[token] = transform(match, nsText)
1063 result = nsText.replacingCharacters(in: match.range, with: token)
1064 }
1065
1066 return result
1067}
1068
1069nonisolated private func replaceMatches(
1070 in text: String,
1071 pattern: String,
1072 transform: (NSTextCheckingResult, NSString) -> String
1073) -> String {
1074 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1075 var result = text
1076 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1077
1078 for match in matches.reversed() {
1079 let nsText = result as NSString
1080 let replacement = transform(match, nsText)
1081 result = nsText.replacingCharacters(in: match.range, with: replacement)
1082 }
1083
1084 return result
1085}
1086
1087nonisolated private func makeOrgImageHTML(
1088 source: String,
1089 alt: String?,
1090 imageURLResolver: ((String) -> String?)?
1091) -> String? {
1092 guard isRenderableImageSource(source) else { return nil }
1093 let resolvedSource = imageURLResolver?(source) ?? source
1094 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1095 let altText = escapeHTMLAttribute(alt ?? "")
1096 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1097}
1098
1099nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1100 let lowercased = source.lowercased()
1101 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1102 .contains(where: { lowercased.hasSuffix($0) })
1103}
1104
1105nonisolated func resolveRepositoryAssetURL(
1106 _ source: String,
1107 owner: String,
1108 repositoryName: String,
1109 readmePath: String?
1110) -> String? {
1111 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1112 guard !trimmedSource.isEmpty else { return nil }
1113
1114 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
1115 return trimmedSource
1116 }
1117
1118 let relativePath: String
1119 if trimmedSource.hasPrefix("/") {
1120 relativePath = String(trimmedSource.dropFirst())
1121 } else {
1122 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
1123 relativePath = normalizeRepositoryPath(
1124 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
1125 )
1126 }
1127
1128 guard !relativePath.isEmpty else { return nil }
1129 var components = URLComponents()
1130 components.scheme = "https"
1131 components.host = "git.sr.ht"
1132 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1133 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1134 let encodedRelativePath = relativePath
1135 .split(separator: "/", omittingEmptySubsequences: false)
1136 .map { segment in
1137 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1138 }
1139 .joined(separator: "/")
1140 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1141 return components.string
1142}
1143
1144nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1145 var components: [String] = []
1146
1147 for part in path.split(separator: "/") {
1148 switch part {
1149 case ".":
1150 continue
1151 case "..":
1152 if !components.isEmpty {
1153 components.removeLast()
1154 }
1155 default:
1156 components.append(String(part))
1157 }
1158 }
1159
1160 return components.joined(separator: "/")
1161}
1162
1163nonisolated private func renderTaskListItem(
1164 _ text: String,
1165 inlineRenderer: (String) -> String
1166) -> String {
1167 let trimmed = text.trimmingCharacters(in: .whitespaces)
1168 guard trimmed.count >= 4 else {
1169 return inlineRenderer(text)
1170 }
1171
1172 let prefix = String(trimmed.prefix(4))
1173 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1174
1175 switch prefix {
1176 case "[ ] ":
1177 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1178 case "[x] ", "[X] ":
1179 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1180 default:
1181 return inlineRenderer(text)
1182 }
1183}
1184
1185// MARK: - WKWebView Wrapper
1186
1187/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
1188struct HTMLWebView: View {
1189 let html: String
1190 let colorScheme: ColorScheme
1191 var style: HTMLWebViewStyle = .readme
1192 @Environment(\.openURL) private var openURL
1193 @State private var contentHeight: CGFloat = 1
1194 @State private var loadError: String?
1195 @State private var reloadToken = 0
1196
1197 var body: some View {
1198 Group {
1199 if let loadError {
1200 SRHTErrorStateView(
1201 title: "Couldn't Render Content",
1202 message: loadError,
1203 retryAction: {
1204 await MainActor.run {
1205 self.loadError = nil
1206 reloadToken += 1
1207 }
1208 }
1209 )
1210 } else {
1211 HTMLWebViewRepresentable(
1212 html: html,
1213 colorScheme: colorScheme,
1214 style: style,
1215 openURL: openURL,
1216 dynamicHeight: $contentHeight,
1217 loadError: $loadError,
1218 reloadToken: reloadToken
1219 )
1220 .frame(height: max(contentHeight, 1))
1221 }
1222 }
1223 }
1224}
1225
1226struct HTMLWebViewStyle: Sendable {
1227 let bodyFontSize: Int
1228 let lineHeight: Double
1229 let codeFontSize: Int
1230 let viewport: String
1231
1232 static let readme = HTMLWebViewStyle(
1233 bodyFontSize: 16,
1234 lineHeight: 1.6,
1235 codeFontSize: 13,
1236 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1237 )
1238
1239 static let commentPreview = HTMLWebViewStyle(
1240 bodyFontSize: 15,
1241 lineHeight: 1.5,
1242 codeFontSize: 12,
1243 viewport: "width=device-width, initial-scale=1, user-scalable=no"
1244 )
1245}
1246
1247private struct HTMLWebViewRepresentable: UIViewRepresentable {
1248 let html: String
1249 let colorScheme: ColorScheme
1250 let style: HTMLWebViewStyle
1251 let openURL: OpenURLAction
1252 @Binding var dynamicHeight: CGFloat
1253 @Binding var loadError: String?
1254 let reloadToken: Int
1255
1256 func makeCoordinator() -> HTMLWebViewCoordinator {
1257 HTMLWebViewCoordinator(parent: self)
1258 }
1259
1260 func makeUIView(context: Context) -> WKWebView {
1261 let config = WKWebViewConfiguration()
1262 config.defaultWebpagePreferences.allowsContentJavaScript = false
1263 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1264 let webView = WKWebView(frame: .zero, configuration: config)
1265 webView.isOpaque = false
1266 webView.backgroundColor = .clear
1267 webView.clipsToBounds = false
1268 webView.allowsLinkPreview = false
1269 webView.scrollView.isScrollEnabled = false
1270 webView.scrollView.contentInsetAdjustmentBehavior = .never
1271 webView.scrollView.clipsToBounds = false
1272 webView.navigationDelegate = context.coordinator
1273 return webView
1274 }
1275
1276 func updateUIView(_ webView: WKWebView, context: Context) {
1277 let textColor = colorScheme == .dark ? "#fff" : "#000"
1278 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1279
1280 let wrapped = """
1281 <!DOCTYPE html>
1282 <html>
1283 <head>
1284 <meta name="viewport" content="\(style.viewport)">
1285 <style>
1286 body {
1287 font-family: -apple-system, system-ui, sans-serif;
1288 font-size: \(style.bodyFontSize)px;
1289 line-height: \(style.lineHeight);
1290 padding: 0;
1291 margin: 0;
1292 color: \(textColor);
1293 background: transparent;
1294 word-wrap: break-word;
1295 overflow-wrap: break-word;
1296 max-width: 100%;
1297 }
1298 * { box-sizing: border-box; }
1299 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1300 p:first-child { margin-top: 0; }
1301 p:last-child { margin-bottom: 0; }
1302 pre, code {
1303 font-family: ui-monospace, Menlo, monospace;
1304 font-size: \(style.codeFontSize)px;
1305 background: rgba(128, 128, 128, 0.15);
1306 padding: 2px 4px;
1307 border-radius: 3px;
1308 }
1309 pre code { padding: 0; background: none; }
1310 pre {
1311 padding: 8px;
1312 overflow-x: auto;
1313 white-space: pre;
1314 word-break: normal;
1315 overflow-wrap: normal;
1316 }
1317 img { max-width: 100%; height: auto; }
1318 input[type="checkbox"] {
1319 margin-right: 0.45rem;
1320 vertical-align: middle;
1321 }
1322 .task-list-item {
1323 display: inline-flex;
1324 align-items: center;
1325 gap: 0.1rem;
1326 }
1327 a { color: \(linkColor); }
1328 table { border-collapse: collapse; width: 100%; }
1329 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1330 blockquote {
1331 border-left: 3px solid rgba(128, 128, 128, 0.5);
1332 margin: 0.5em 0;
1333 padding: 0.25em 0 0.25em 1em;
1334 color: inherit;
1335 opacity: 0.85;
1336 }
1337 hr {
1338 border: none;
1339 border-top: 1px solid rgba(128, 128, 128, 0.35);
1340 margin: 1em 0;
1341 }
1342 table {
1343 border-collapse: collapse;
1344 width: 100%;
1345 margin: 0.75em 0;
1346 font-size: 0.95em;
1347 }
1348 th {
1349 background: rgba(128, 128, 128, 0.15);
1350 font-weight: 600;
1351 text-align: left;
1352 }
1353 td, th {
1354 border: 1px solid rgba(128, 128, 128, 0.3);
1355 padding: 6px 10px;
1356 }
1357 dl.org-properties {
1358 margin: 0.5em 0;
1359 display: grid;
1360 grid-template-columns: max-content 1fr;
1361 gap: 2px 12px;
1362 }
1363 dt {
1364 font-weight: 600;
1365 font-family: ui-monospace, Menlo, monospace;
1366 font-size: 0.9em;
1367 }
1368 dd { margin: 0; }
1369 .org-metadata { margin-bottom: 1em; }
1370 .org-title { margin: 0 0 0.25em; }
1371 .org-author, .org-date {
1372 margin: 0;
1373 color: rgba(128, 128, 128, 0.85);
1374 font-size: 0.9em;
1375 }
1376 del { opacity: 0.7; }
1377 </style>
1378 </head>
1379 <body>\(html)</body>
1380 </html>
1381 """
1382
1383 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1384 let height = CGFloat(cachedHeight)
1385 if abs(dynamicHeight - height) > 0.5 {
1386 DispatchQueue.main.async {
1387 if abs(self.dynamicHeight - height) > 0.5 {
1388 self.dynamicHeight = height
1389 }
1390 }
1391 }
1392 }
1393
1394 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1395 context.coordinator.lastHTML = wrapped
1396 context.coordinator.lastReloadToken = reloadToken
1397 if loadError != nil {
1398 DispatchQueue.main.async {
1399 self.loadError = nil
1400 }
1401 }
1402 webView.loadHTMLString(wrapped, baseURL: nil)
1403 }
1404}
1405
1406private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1407 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1408 static let heightCache = NSCache<NSString, NSNumber>()
1409
1410 let parent: HTMLWebViewRepresentable
1411 var lastHTML: String?
1412 var lastReloadToken = 0
1413
1414 init(parent: HTMLWebViewRepresentable) {
1415 self.parent = parent
1416 }
1417
1418 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1419 DispatchQueue.main.async {
1420 self.parent.loadError = nil
1421 }
1422 updateHeight(for: webView)
1423 }
1424
1425 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1426 handleLoadFailure(error)
1427 }
1428
1429 func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1430 handleLoadFailure(error)
1431 }
1432
1433 func webView(
1434 _ webView: WKWebView,
1435 decidePolicyFor navigationAction: WKNavigationAction,
1436 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1437 ) {
1438 guard let requestURL = navigationAction.request.url else {
1439 decisionHandler(.allow)
1440 return
1441 }
1442
1443 if navigationAction.navigationType == .linkActivated {
1444 if isAllowedReadmeNavigationURL(requestURL) {
1445 parent.openURL(requestURL)
1446 }
1447 decisionHandler(.cancel)
1448 return
1449 }
1450
1451 if isAllowedReadmeNavigationURL(requestURL) {
1452 decisionHandler(.allow)
1453 } else {
1454 decisionHandler(.cancel)
1455 }
1456 }
1457
1458 private func handleLoadFailure(_ error: Error) {
1459 let nsError = error as NSError
1460 guard nsError.code != NSURLErrorCancelled else { return }
1461 DispatchQueue.main.async {
1462 self.parent.loadError = "The content could not be displayed right now."
1463 }
1464 }
1465
1466 private func updateHeight(for webView: WKWebView) {
1467 webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1468 guard let self else { return }
1469 guard let heightValue = result as? NSNumber else { return }
1470 let height = CGFloat(heightValue.doubleValue)
1471 guard height > 0 else { return }
1472 let rounded = ceil(height) + 4
1473 DispatchQueue.main.async {
1474 if let html = self.lastHTML {
1475 Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1476 }
1477 if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1478 self.parent.dynamicHeight = rounded
1479 }
1480 }
1481 }
1482 }
1483}