krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.4.1: 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 markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280 let normalizedText = text
281 .replacingOccurrences(of: "\r\n", with: "\n")
282 .replacingOccurrences(of: "\r", with: "\n")
283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
284 var html = ""
285 var inCodeBlock = false
286 var inList = false
287 var paragraph: [String] = []
288
289 func flushParagraph() {
290 if !paragraph.isEmpty {
291 let normalizedParagraph = paragraph
292 .map { $0.trimmingCharacters(in: .whitespaces) }
293 .joined(separator: " ")
294 html += "<p>" + normalizedParagraph + "</p>\n"
295 paragraph = []
296 }
297 }
298
299 func closeList() {
300 if inList {
301 html += "</ul>\n"
302 inList = false
303 }
304 }
305
306 for line in lines {
307 // Fenced code blocks
308 if line.hasPrefix("```") {
309 if inCodeBlock {
310 html += "</code></pre>\n"
311 inCodeBlock = false
312 } else {
313 flushParagraph()
314 closeList()
315 html += "<pre><code>"
316 inCodeBlock = true
317 }
318 continue
319 }
320
321 if inCodeBlock {
322 html += escapeHTML(line) + "\n"
323 continue
324 }
325
326 // Headings
327 if line.hasPrefix("### ") {
328 flushParagraph()
329 closeList()
330 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
331 continue
332 }
333 if line.hasPrefix("## ") {
334 flushParagraph()
335 closeList()
336 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
337 continue
338 }
339 if line.hasPrefix("# ") {
340 flushParagraph()
341 closeList()
342 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
343 continue
344 }
345
346 // List items
347 let trimmed = line.trimmingCharacters(in: .whitespaces)
348 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
349 flushParagraph()
350 if !inList {
351 html += "<ul>\n"
352 inList = true
353 }
354 html += "<li>" + renderTaskListItem(
355 String(trimmed.dropFirst(2)),
356 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
357 ) + "</li>\n"
358 continue
359 }
360
361 // Blank line
362 if trimmed.isEmpty {
363 flushParagraph()
364 closeList()
365 continue
366 }
367
368 // Regular text — accumulate into paragraph
369 paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
370 }
371
372 // Flush remaining state
373 if inCodeBlock {
374 html += "</code></pre>\n"
375 }
376 flushParagraph()
377 closeList()
378
379 return html
380}
381
382nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
383 var result = escapeHTML(text)
384
385 // Images: 
386 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
387 let alt = nsText.substring(with: match.range(at: 1))
388 let source = nsText.substring(with: match.range(at: 2))
389 let resolvedSource = imageURLResolver?(source) ?? source
390 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
391 return escapeHTML(alt)
392 }
393 return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
394 }
395 // Links: [text](url)
396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
397 let label = nsText.substring(with: match.range(at: 1))
398 let rawURL = nsText.substring(with: match.range(at: 2))
399 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
400 return label
401 }
402 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
403 }
404 // Bold: **text**
405 result = result.replacingOccurrences(
406 of: #"\*\*(.+?)\*\*"#,
407 with: "<strong>$1</strong>",
408 options: .regularExpression
409 )
410 // Italic: *text*
411 result = result.replacingOccurrences(
412 of: #"\*(.+?)\*"#,
413 with: "<em>$1</em>",
414 options: .regularExpression
415 )
416 // Inline code: `text`
417 result = result.replacingOccurrences(
418 of: #"`([^`]+)`"#,
419 with: "<code>$1</code>",
420 options: .regularExpression
421 )
422
423 return result
424}
425
426// MARK: - Org-mode to HTML
427
428nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
429 let normalizedText = text
430 .replacingOccurrences(of: "\r\n", with: "\n")
431 .replacingOccurrences(of: "\r", with: "\n")
432 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
433 var html = ""
434 var listType: OrgListType?
435 var inQuoteBlock = false
436 var inPropertyDrawer = false
437 var srcLanguage: String?
438 var paragraph: [String] = []
439 var tableRows: [[String]] = []
440 var propertyRows: [(String, String)] = []
441
442 func flushParagraph() {
443 if !paragraph.isEmpty {
444 let normalizedParagraph = paragraph
445 .map { $0.trimmingCharacters(in: .whitespaces) }
446 .joined(separator: " ")
447 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
448 paragraph = []
449 }
450 }
451
452 func closeList() {
453 switch listType {
454 case .unordered:
455 html += "</ul>\n"
456 case .ordered:
457 html += "</ol>\n"
458 case nil:
459 break
460 }
461 listType = nil
462 }
463
464 func flushTable() {
465 guard !tableRows.isEmpty else { return }
466 let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
467 let headerRow = tableRows.first ?? []
468 let bodyRows: [[String]]
469
470 html += "<table>\n"
471 if hasHeaderSeparator {
472 html += "<thead><tr>"
473 for cell in headerRow {
474 html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
475 }
476 html += "</tr></thead>\n<tbody>\n"
477 bodyRows = Array(tableRows.dropFirst(2))
478 } else {
479 bodyRows = tableRows
480 }
481
482 for row in bodyRows {
483 html += "<tr>"
484 for cell in row {
485 html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
486 }
487 html += "</tr>\n"
488 }
489
490 if hasHeaderSeparator {
491 html += "</tbody>\n"
492 }
493 html += "</table>\n"
494 tableRows = []
495 }
496
497 func flushPropertyDrawer() {
498 guard !propertyRows.isEmpty else { return }
499 html += "<dl class=\"org-properties\">\n"
500 for (key, value) in propertyRows {
501 html += "<dt>" + escapeHTML(key) + "</dt>"
502 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
503 }
504 html += "</dl>\n"
505 propertyRows = []
506 }
507
508 func closeQuoteBlock() {
509 if inQuoteBlock {
510 flushParagraph()
511 html += "</blockquote>\n"
512 inQuoteBlock = false
513 }
514 }
515
516 func closeSourceBlock() {
517 if srcLanguage != nil {
518 html += "</code></pre>\n"
519 srcLanguage = nil
520 }
521 }
522
523 func flushBlockState() {
524 flushParagraph()
525 closeList()
526 flushTable()
527 flushPropertyDrawer()
528 }
529
530 for line in lines {
531 let trimmed = line.trimmingCharacters(in: .whitespaces)
532
533 if srcLanguage != nil {
534 if trimmed.lowercased() == "#+end_src" {
535 closeSourceBlock()
536 } else {
537 html += escapeHTML(line) + "\n"
538 }
539 continue
540 }
541
542 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
543 closeQuoteBlock()
544 continue
545 }
546
547 if trimmed.lowercased().hasPrefix("#+begin_src") {
548 closeQuoteBlock()
549 flushBlockState()
550 let language = trimmed
551 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
552 .dropFirst()
553 .first
554 .map(String.init)?
555 .trimmingCharacters(in: .whitespacesAndNewlines)
556 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
557 html += "<pre><code\(classAttribute)>"
558 srcLanguage = language ?? ""
559 continue
560 }
561
562 if trimmed.lowercased() == "#+begin_quote" {
563 flushBlockState()
564 html += "<blockquote>\n"
565 inQuoteBlock = true
566 continue
567 }
568
569 if trimmed == ":PROPERTIES:" {
570 closeQuoteBlock()
571 flushBlockState()
572 inPropertyDrawer = true
573 continue
574 }
575
576 if trimmed == ":END:", inPropertyDrawer {
577 flushPropertyDrawer()
578 inPropertyDrawer = false
579 continue
580 }
581
582 if inPropertyDrawer,
583 trimmed.hasPrefix(":"),
584 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
585 let keyStart = trimmed.index(after: trimmed.startIndex)
586 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
587 let valueStart = trimmed.index(after: secondColonIndex)
588 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
589 if !key.isEmpty {
590 propertyRows.append((key, value))
591 continue
592 }
593 }
594
595 if isOrgTableLine(trimmed) {
596 closeQuoteBlock()
597 flushParagraph()
598 closeList()
599 tableRows.append(parseOrgTableRow(trimmed))
600 continue
601 } else {
602 flushTable()
603 }
604
605 // Org headings: * heading, ** heading, *** heading
606 if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
607 closeQuoteBlock()
608 flushBlockState()
609 let level = match.1.count
610 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
611 html += "<h\(level)>" + content + "</h\(level)>\n"
612 continue
613 }
614
615 // List items: - item
616 if trimmed.hasPrefix("- ") {
617 flushParagraph()
618 flushPropertyDrawer()
619 if listType != .unordered {
620 closeList()
621 html += "<ul>\n"
622 listType = .unordered
623 }
624 html += "<li>" + renderTaskListItem(
625 String(trimmed.dropFirst(2)),
626 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
627 ) + "</li>\n"
628 continue
629 }
630
631 if let orderedItem = orderedListItem(in: trimmed) {
632 flushParagraph()
633 flushPropertyDrawer()
634 if listType != .ordered {
635 closeList()
636 html += "<ol>\n"
637 listType = .ordered
638 }
639 html += "<li>" + renderTaskListItem(
640 orderedItem,
641 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
642 ) + "</li>\n"
643 continue
644 }
645
646 // Blank line
647 if trimmed.isEmpty {
648 if inQuoteBlock {
649 flushParagraph()
650 } else {
651 flushBlockState()
652 }
653 continue
654 }
655
656 // Regular text
657 paragraph.append(line)
658 }
659
660 closeSourceBlock()
661 closeQuoteBlock()
662 flushBlockState()
663
664 return html
665}
666
667nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
668 var result = escapeHTML(text)
669 var protectedFragments: [String: String] = [:]
670
671 result = protectMatches(
672 in: result,
673 pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
674 protectedFragments: &protectedFragments
675 ) { match, nsText in
676 let url = nsText.substring(with: match.range(at: 1))
677 let label = nsText.substring(with: match.range(at: 2))
678 if let imageHTML = makeOrgImageHTML(
679 source: url,
680 alt: label,
681 imageURLResolver: imageURLResolver
682 ) {
683 return imageHTML
684 }
685 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
686 return label
687 }
688 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
689 }
690 result = protectMatches(
691 in: result,
692 pattern: #"\[\[([^\]]+)\]\]"#,
693 protectedFragments: &protectedFragments
694 ) { match, nsText in
695 let url = nsText.substring(with: match.range(at: 1))
696 if let imageHTML = makeOrgImageHTML(
697 source: url,
698 alt: nil,
699 imageURLResolver: imageURLResolver
700 ) {
701 return imageHTML
702 }
703 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
704 return url
705 }
706 return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
707 }
708 result = protectMatches(
709 in: result,
710 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
711 protectedFragments: &protectedFragments
712 ) { match, nsText in
713 let tildeRange = match.range(at: 1)
714 let equalsRange = match.range(at: 2)
715 let codeText: String
716 if tildeRange.location != NSNotFound {
717 codeText = nsText.substring(with: tildeRange)
718 } else {
719 codeText = nsText.substring(with: equalsRange)
720 }
721 return "<code>\(codeText)</code>"
722 }
723
724 // Bold: *text*
725 result = result.replacingOccurrences(
726 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
727 with: "<strong>$1</strong>",
728 options: .regularExpression
729 )
730 // Italic: /text/
731 result = result.replacingOccurrences(
732 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
733 with: "<em>$1</em>",
734 options: .regularExpression
735 )
736
737 for (token, fragment) in protectedFragments {
738 result = result.replacingOccurrences(of: token, with: fragment)
739 }
740
741 return result
742}
743
744// MARK: - HTML Escaping
745
746nonisolated func escapeHTML(_ text: String) -> String {
747 text.replacingOccurrences(of: "&", with: "&")
748 .replacingOccurrences(of: "<", with: "<")
749 .replacingOccurrences(of: ">", with: ">")
750 .replacingOccurrences(of: "\"", with: """)
751}
752
753nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
754 escapeHTML(text).replacingOccurrences(of: "'", with: "'")
755}
756
757nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
758 sanitizeReadmeURLString(
759 rawURL,
760 allowedSchemes: ["http", "https", "mailto"],
761 allowsFragmentOnly: true
762 )
763}
764
765nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
766 sanitizeReadmeURLString(
767 rawURL,
768 allowedSchemes: ["http", "https"],
769 allowsFragmentOnly: false
770 )
771}
772
773nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
774 guard let scheme = url.scheme?.lowercased() else {
775 return false
776 }
777 if scheme == "about" || scheme == "data" {
778 return true
779 }
780 guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
781 return false
782 }
783 return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
784}
785
786nonisolated private func sanitizeReadmeURLString(
787 _ rawURL: String,
788 allowedSchemes: Set<String>,
789 allowsFragmentOnly: Bool
790) -> String? {
791 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
792 guard !trimmedURL.isEmpty else { return nil }
793
794 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
795 return escapeHTMLAttribute(trimmedURL)
796 }
797
798 guard let components = URLComponents(string: trimmedURL),
799 let scheme = components.scheme?.lowercased(),
800 allowedSchemes.contains(scheme),
801 let sanitizedURL = components.url?.absoluteString else {
802 return nil
803 }
804
805 return escapeHTMLAttribute(sanitizedURL)
806}
807
808nonisolated private func isOrgTableLine(_ line: String) -> Bool {
809 line.hasPrefix("|") && line.hasSuffix("|")
810}
811
812nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
813 line
814 .split(separator: "|", omittingEmptySubsequences: false)
815 .dropFirst()
816 .dropLast()
817 .map { String($0).trimmingCharacters(in: .whitespaces) }
818}
819
820nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
821 let trimmed = cell.trimmingCharacters(in: .whitespaces)
822 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
823}
824
825private enum OrgListType {
826 case unordered
827 case ordered
828}
829
830nonisolated private func orderedListItem(in line: String) -> String? {
831 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
832 return String(match.2)
833}
834
835nonisolated private func protectMatches(
836 in text: String,
837 pattern: String,
838 protectedFragments: inout [String: String],
839 transform: (NSTextCheckingResult, NSString) -> String
840) -> String {
841 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
842 var result = text
843 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
844
845 for match in matches.reversed() {
846 let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
847 let nsText = result as NSString
848 protectedFragments[token] = transform(match, nsText)
849 result = nsText.replacingCharacters(in: match.range, with: token)
850 }
851
852 return result
853}
854
855nonisolated private func replaceMatches(
856 in text: String,
857 pattern: String,
858 transform: (NSTextCheckingResult, NSString) -> String
859) -> String {
860 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
861 var result = text
862 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
863
864 for match in matches.reversed() {
865 let nsText = result as NSString
866 let replacement = transform(match, nsText)
867 result = nsText.replacingCharacters(in: match.range, with: replacement)
868 }
869
870 return result
871}
872
873nonisolated private func makeOrgImageHTML(
874 source: String,
875 alt: String?,
876 imageURLResolver: ((String) -> String?)?
877) -> String? {
878 guard isRenderableImageSource(source) else { return nil }
879 let resolvedSource = imageURLResolver?(source) ?? source
880 let altText = escapeHTMLAttribute(alt ?? "")
881 return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
882}
883
884nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
885 let lowercased = source.lowercased()
886 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
887 .contains(where: { lowercased.hasSuffix($0) })
888}
889
890nonisolated func resolveRepositoryAssetURL(
891 _ source: String,
892 owner: String,
893 repositoryName: String,
894 readmePath: String?
895) -> String? {
896 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
897 guard !trimmedSource.isEmpty else { return nil }
898
899 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
900 return trimmedSource
901 }
902
903 let relativePath: String
904 if trimmedSource.hasPrefix("/") {
905 relativePath = String(trimmedSource.dropFirst())
906 } else {
907 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
908 relativePath = normalizeRepositoryPath(
909 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
910 )
911 }
912
913 guard !relativePath.isEmpty else { return nil }
914 return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
915}
916
917nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
918 var components: [String] = []
919
920 for part in path.split(separator: "/") {
921 switch part {
922 case ".":
923 continue
924 case "..":
925 if !components.isEmpty {
926 components.removeLast()
927 }
928 default:
929 components.append(String(part))
930 }
931 }
932
933 return components.joined(separator: "/")
934}
935
936nonisolated private func renderTaskListItem(
937 _ text: String,
938 inlineRenderer: (String) -> String
939) -> String {
940 let trimmed = text.trimmingCharacters(in: .whitespaces)
941 guard trimmed.count >= 4 else {
942 return inlineRenderer(text)
943 }
944
945 let prefix = String(trimmed.prefix(4))
946 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
947
948 switch prefix {
949 case "[ ] ":
950 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
951 case "[x] ", "[X] ":
952 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
953 default:
954 return inlineRenderer(text)
955 }
956}
957
958// MARK: - WKWebView Wrapper
959
960/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
961struct HTMLWebView: View {
962 let html: String
963 let colorScheme: ColorScheme
964 var style: HTMLWebViewStyle = .readme
965 @Environment(\.openURL) private var openURL
966 @State private var contentHeight: CGFloat = 1
967 @State private var loadError: String?
968 @State private var reloadToken = 0
969
970 var body: some View {
971 Group {
972 if let loadError {
973 SRHTErrorStateView(
974 title: "Couldn't Render Content",
975 message: loadError,
976 retryAction: {
977 await MainActor.run {
978 self.loadError = nil
979 reloadToken += 1
980 }
981 }
982 )
983 } else {
984 HTMLWebViewRepresentable(
985 html: html,
986 colorScheme: colorScheme,
987 style: style,
988 openURL: openURL,
989 dynamicHeight: $contentHeight,
990 loadError: $loadError,
991 reloadToken: reloadToken
992 )
993 .frame(height: max(contentHeight, 1))
994 }
995 }
996 }
997}
998
999struct HTMLWebViewStyle: Sendable {
1000 let bodyFontSize: Int
1001 let lineHeight: Double
1002 let codeFontSize: Int
1003 let viewport: String
1004
1005 static let readme = HTMLWebViewStyle(
1006 bodyFontSize: 16,
1007 lineHeight: 1.6,
1008 codeFontSize: 13,
1009 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1010 )
1011
1012 static let commentPreview = HTMLWebViewStyle(
1013 bodyFontSize: 15,
1014 lineHeight: 1.5,
1015 codeFontSize: 12,
1016 viewport: "width=device-width, initial-scale=1, user-scalable=no"
1017 )
1018}
1019
1020private struct HTMLWebViewRepresentable: UIViewRepresentable {
1021 let html: String
1022 let colorScheme: ColorScheme
1023 let style: HTMLWebViewStyle
1024 let openURL: OpenURLAction
1025 @Binding var dynamicHeight: CGFloat
1026 @Binding var loadError: String?
1027 let reloadToken: Int
1028
1029 func makeCoordinator() -> HTMLWebViewCoordinator {
1030 HTMLWebViewCoordinator(parent: self)
1031 }
1032
1033 func makeUIView(context: Context) -> WKWebView {
1034 let config = WKWebViewConfiguration()
1035 config.defaultWebpagePreferences.allowsContentJavaScript = false
1036 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1037 let webView = WKWebView(frame: .zero, configuration: config)
1038 webView.isOpaque = false
1039 webView.backgroundColor = .clear
1040 webView.clipsToBounds = false
1041 webView.allowsLinkPreview = false
1042 webView.scrollView.isScrollEnabled = false
1043 webView.scrollView.contentInsetAdjustmentBehavior = .never
1044 webView.scrollView.clipsToBounds = false
1045 webView.navigationDelegate = context.coordinator
1046 return webView
1047 }
1048
1049 func updateUIView(_ webView: WKWebView, context: Context) {
1050 let textColor = colorScheme == .dark ? "#fff" : "#000"
1051 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1052
1053 let wrapped = """
1054 <!DOCTYPE html>
1055 <html>
1056 <head>
1057 <meta name="viewport" content="\(style.viewport)">
1058 <style>
1059 body {
1060 font-family: -apple-system, system-ui, sans-serif;
1061 font-size: \(style.bodyFontSize)px;
1062 line-height: \(style.lineHeight);
1063 padding: 0;
1064 margin: 0;
1065 color: \(textColor);
1066 background: transparent;
1067 word-wrap: break-word;
1068 overflow-wrap: break-word;
1069 max-width: 100%;
1070 }
1071 * { box-sizing: border-box; }
1072 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1073 p:first-child { margin-top: 0; }
1074 p:last-child { margin-bottom: 0; }
1075 pre, code {
1076 font-family: ui-monospace, Menlo, monospace;
1077 font-size: \(style.codeFontSize)px;
1078 background: rgba(128, 128, 128, 0.15);
1079 padding: 2px 4px;
1080 border-radius: 3px;
1081 }
1082 pre code { padding: 0; background: none; }
1083 pre {
1084 padding: 8px;
1085 overflow-x: auto;
1086 white-space: pre-wrap;
1087 word-wrap: break-word;
1088 }
1089 img { max-width: 100%; height: auto; }
1090 input[type="checkbox"] {
1091 margin-right: 0.45rem;
1092 vertical-align: middle;
1093 }
1094 .task-list-item {
1095 display: inline-flex;
1096 align-items: center;
1097 gap: 0.1rem;
1098 }
1099 a { color: \(linkColor); }
1100 table { border-collapse: collapse; width: 100%; }
1101 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1102 </style>
1103 </head>
1104 <body>\(html)</body>
1105 </html>
1106 """
1107
1108 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1109 let height = CGFloat(cachedHeight)
1110 if abs(dynamicHeight - height) > 0.5 {
1111 DispatchQueue.main.async {
1112 if abs(self.dynamicHeight - height) > 0.5 {
1113 self.dynamicHeight = height
1114 }
1115 }
1116 }
1117 }
1118
1119 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1120 context.coordinator.lastHTML = wrapped
1121 context.coordinator.lastReloadToken = reloadToken
1122 if loadError != nil {
1123 DispatchQueue.main.async {
1124 self.loadError = nil
1125 }
1126 }
1127 webView.loadHTMLString(wrapped, baseURL: nil)
1128 }
1129}
1130
1131private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1132 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1133 static let heightCache = NSCache<NSString, NSNumber>()
1134
1135 let parent: HTMLWebViewRepresentable
1136 var lastHTML: String?
1137 var lastReloadToken = 0
1138
1139 init(parent: HTMLWebViewRepresentable) {
1140 self.parent = parent
1141 }
1142
1143 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1144 DispatchQueue.main.async {
1145 self.parent.loadError = nil
1146 }
1147 updateHeight(for: webView)
1148 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
1149 guard let self, let webView else { return }
1150 self.updateHeight(for: webView)
1151 }
1152 }
1153
1154 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1155 handleLoadFailure(error)
1156 }
1157
1158 func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1159 handleLoadFailure(error)
1160 }
1161
1162 func webView(
1163 _ webView: WKWebView,
1164 decidePolicyFor navigationAction: WKNavigationAction,
1165 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1166 ) {
1167 guard let requestURL = navigationAction.request.url else {
1168 decisionHandler(.allow)
1169 return
1170 }
1171
1172 if navigationAction.navigationType == .linkActivated {
1173 if isAllowedReadmeNavigationURL(requestURL) {
1174 parent.openURL(requestURL)
1175 }
1176 decisionHandler(.cancel)
1177 return
1178 }
1179
1180 if isAllowedReadmeNavigationURL(requestURL) {
1181 decisionHandler(.allow)
1182 } else {
1183 decisionHandler(.cancel)
1184 }
1185 }
1186
1187 private func handleLoadFailure(_ error: Error) {
1188 let nsError = error as NSError
1189 guard nsError.code != NSURLErrorCancelled else { return }
1190 DispatchQueue.main.async {
1191 self.parent.loadError = "The content could not be displayed right now."
1192 }
1193 }
1194
1195 private func updateHeight(for webView: WKWebView) {
1196 webView.layoutIfNeeded()
1197 let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
1198 guard height > 0 else { return }
1199 DispatchQueue.main.async {
1200 if let html = self.lastHTML {
1201 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1202 }
1203 if abs(self.parent.dynamicHeight - height) > 0.5 {
1204 self.parent.dynamicHeight = height
1205 }
1206 }
1207 }
1208}