Hutch/Views/Repositories/ReadmeView.swift
1023 lines · 37109 bytes
1import OrgSwift
2import OrgSwiftUI
3import SwiftUI
4import WebKit
5
6struct ReadmeView: View {
7 @Environment(AppState.self) private var appState
8 let viewModel: RepositoryDetailViewModel
9
10 @Environment(\.colorScheme) private var colorScheme
11 @State private var isShowingRepositoryDetails = false
12 @State private var linkedFile: LinkedFileRequest?
13
14 var body: some View {
15 ScrollView {
16 VStack(alignment: .leading, spacing: 16) {
17 headerSection
18 metadataSection
19 repositoryDetailsSection
20 latestChangeSection
21 readmeSection
22 if appState.isDebugModeEnabled {
23 debugSection
24 }
25 }
26 .padding()
27 }
28 .sheet(item: $linkedFile) { request in
29 LinkedFileSheetView(
30 rid: viewModel.repository.rid,
31 service: viewModel.repository.service,
32 client: appState.client,
33 request: request
34 )
35 }
36 .task {
37 async let readme: () = viewModel.loadReadme()
38 async let commits: () = viewModel.loadCommits()
39 async let refs: () = viewModel.loadReferences()
40 _ = await (readme, commits, refs)
41 }
42 .navigationDestination(for: CommitSummary.self) { commit in
43 CommitDetailView(
44 commitSummary: commit,
45 repository: viewModel.repository
46 )
47 }
48 }
49
50 @ViewBuilder
51 private var headerSection: some View {
52 VStack(alignment: .leading, spacing: 6) {
53 Text(viewModel.repository.owner.canonicalName)
54 .font(.subheadline)
55 .foregroundStyle(.secondary)
56 Text(viewModel.repository.name)
57 .font(.largeTitle.weight(.semibold))
58 if let description = viewModel.repository.description, !description.isEmpty {
59 Text(description)
60 .font(.body)
61 }
62 }
63 }
64
65 @ViewBuilder
66 private var metadataSection: some View {
67 VStack(alignment: .leading, spacing: 10) {
68 if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
69 SummaryMetadataRow(
70 icon: "arrow.triangle.branch",
71 title: branchLabel
72 )
73 }
74
75 if let readmePath = viewModel.readmePath {
76 SummaryMetadataRow(
77 icon: "doc.text",
78 title: readmePath
79 )
80 }
81 }
82 }
83
84 private var repositoryDetailsSection: some View {
85 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
86 VStack(alignment: .leading, spacing: 12) {
87 SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
88 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
89 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
90 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
91 SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
92 }
93 .padding(.top, 8)
94 } label: {
95 Text("Repository Details")
96 .font(.subheadline.weight(.medium))
97 }
98 }
99
100 @ViewBuilder
101 private var latestChangeSection: some View {
102 VStack(alignment: .leading, spacing: 8) {
103 if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
104 SRHTLoadingStateView(message: "Loading latest change…")
105 .frame(maxWidth: .infinity)
106 } else if let commit = viewModel.commits.first {
107 NavigationLink(value: commit) {
108 SummaryMetadataRow(
109 icon: "arrow.trianglehead.clockwise",
110 title: commit.title,
111 subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
112 )
113 .contentShape(Rectangle())
114 }
115 .buttonStyle(.plain)
116 } else if let error = viewModel.error, viewModel.commits.isEmpty {
117 SRHTErrorStateView(
118 title: "Couldn't Load Latest Change",
119 message: error,
120 retryAction: { await viewModel.loadCommits() }
121 )
122 } else {
123 ContentUnavailableView(
124 "No Recent Commits",
125 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
126 description: Text("This repository does not have any commit history yet.")
127 )
128 }
129 }
130 }
131
132 @ViewBuilder
133 private var readmeSection: some View {
134 if viewModel.isLoadingReadme {
135 SRHTLoadingStateView(message: "Loading README…")
136 } else if let content = viewModel.readmeContent {
137 RenderedMarkupContentView(
138 content: sharedReadmeContent(from: content),
139 readmePath: viewModel.readmePath,
140 colorScheme: colorScheme,
141 ownerCanonicalName: viewModel.repository.owner.canonicalName,
142 repositoryName: viewModel.repository.name,
143 onInterceptURL: { url in
144 guard let request = parseLinkedFileRequest(url) else { return false }
145 linkedFile = request
146 return true
147 }
148 )
149 } else if let error = viewModel.error, !viewModel.readmeLoaded {
150 SRHTErrorStateView(
151 title: "Couldn't Load README",
152 message: error,
153 retryAction: { await viewModel.loadReadme() }
154 )
155 } else {
156 ContentUnavailableView(
157 "No README",
158 systemImage: "doc.text",
159 description: Text("This repository does not have a README file.")
160 )
161 }
162 }
163
164 /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest`
165 /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`.
166 /// Returns `nil` for any other URL (external links, fragment links, etc.).
167 private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? {
168 let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht"
169 guard let host = url.host, host == expectedHost else { return nil }
170
171 // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file
172 // → ["/", "~owner", "repo", "blob", "HEAD", "file"]
173 let parts = url.pathComponents
174 guard parts.count >= 6,
175 parts[1] == viewModel.repository.owner.canonicalName,
176 parts[2] == viewModel.repository.name,
177 parts[3] == "blob" else { return nil }
178
179 let revspec = parts[4]
180 let path = parts[5...].joined(separator: "/")
181 guard !path.isEmpty else { return nil }
182
183 let fileName = parts.last ?? path
184 return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName)
185 }
186
187 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
188 switch content {
189 case .html(let html):
190 .html(html)
191 case .markdown(let text):
192 .markdown(text)
193 case .org(let text):
194 .org(text)
195 case .plainText(let text):
196 .plainText(text)
197 }
198 }
199
200 private var debugSection: some View {
201 DebugTextBlock(
202 title: "Debug",
203 content: """
204 repositoryId: \(viewModel.repository.id)
205 rid: \(viewModel.repository.rid)
206 service: \(viewModel.repository.service.rawValue)
207 defaultBranch: \(viewModel.repository.defaultBranchName ?? "none")
208 webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable")
209 httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable")
210 sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository))
211 readmePath: \(viewModel.readmePath ?? "none")
212 commitsLoaded: \(viewModel.commits.count)
213 branchesLoaded: \(viewModel.branches.count)
214 tagsLoaded: \(viewModel.tags.count)
215 """
216 )
217 }
218}
219
220enum RenderedMarkupContent: Sendable {
221 case html(String)
222 case markdown(String)
223 case org(String)
224 case plainText(String)
225}
226
227struct RenderedMarkupContentView: View {
228 let content: RenderedMarkupContent
229 let readmePath: String?
230 let colorScheme: ColorScheme
231 let ownerCanonicalName: String
232 let repositoryName: String
233 var repositoryHost = "git.sr.ht"
234 var onInterceptURL: ((URL) -> Bool)? = nil
235
236 @State private var renderedHTML: String?
237
238 private var cacheKey: String {
239 // Highlighted code colors are baked into the HTML, so the theme is part
240 // of the cache identity — light and dark must not share an entry.
241 let theme = colorScheme == .dark ? "dark" : "light"
242 switch content {
243 case .html(let html):
244 return "html:\(readmePath ?? "custom"):\(html)"
245 case .markdown(let text):
246 return "markdown:\(theme):\(readmePath ?? ""):\(text)"
247 case .org(let text):
248 return "org:\(readmePath ?? ""):\(text)"
249 case .plainText(let text):
250 return "plain:\(readmePath ?? ""):\(text)"
251 }
252 }
253
254 var body: some View {
255 Group {
256 switch content {
257 case .html(let html):
258 HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
259 case .markdown:
260 if let renderedHTML {
261 HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
262 } else {
263 SRHTLoadingStateView(message: "Preparing README…")
264 }
265 case .org(let text):
266 OrgView(
267 text,
268 options: renderOptions,
269 styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)),
270 // Matches how tickets are colored: pending amber, resolved green.
271 keywords: OrgKeywordStyle(todo: .orange, done: .green)
272 )
273 .environment(\.openURL, OpenURLAction { url in
274 // Links to files in this repository open in-app, as they did
275 // when the web view intercepted its own navigation.
276 if onInterceptURL?(url) == true { return .handled }
277 return .systemAction
278 })
279 case .plainText(let text):
280 Text(text)
281 .font(.system(.body, design: .monospaced))
282 .frame(maxWidth: .infinity, alignment: .leading)
283 }
284 }
285 .task(id: cacheKey) {
286 await prepareHTMLIfNeeded()
287 }
288 }
289
290 private func prepareHTMLIfNeeded() async {
291 switch content {
292 case .html, .plainText, .org:
293 renderedHTML = nil
294 case .markdown(let text):
295 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
296 renderedHTML = cached
297 return
298 }
299 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
300 let options = renderOptions
301 let html = await Task.detached(priority: .userInitiated) {
302 markdownToHTML(
303 text,
304 codeTheme: theme,
305 imageURLResolver: { options.resolvedImageURL($0) },
306 linkURLResolver: { options.resolvedLinkURL($0) }
307 )
308 }.value
309 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
310 guard !Task.isCancelled else { return }
311 renderedHTML = html
312 }
313 }
314
315 /// One resolution for both formats: a relative link in a Markdown README points at the
316 /// same file page an Org one does, on whichever instance is being browsed.
317 private var renderOptions: OrgRenderOptions {
318 OrgRenderOptions(
319 host: repositoryHost,
320 owner: ownerCanonicalName,
321 repositoryName: repositoryName,
322 ref: "HEAD",
323 readmePath: readmePath
324 )
325 }
326}
327
328private final class RenderedReadmeHTMLCache: @unchecked Sendable {
329 static let shared = RenderedReadmeHTMLCache()
330
331 private let storage = NSCache<NSString, NSString>()
332
333 func html(forKey key: String) -> String? {
334 storage.object(forKey: key as NSString) as String?
335 }
336
337 func setHTML(_ html: String, forKey key: String) {
338 storage.setObject(html as NSString, forKey: key as NSString)
339 }
340
341 func removeAll() {
342 storage.removeAllObjects()
343 }
344}
345
346@MainActor
347func clearWebContentRenderCaches() {
348 RenderedReadmeHTMLCache.shared.removeAll()
349 HTMLWebViewCoordinator.heightCache.removeAllObjects()
350}
351
352// MARK: - Markdown to HTML
353
354nonisolated func processInline(
355 _ text: String,
356 imageURLResolver: ((String) -> String?)? = nil,
357 linkURLResolver: ((String) -> String?)? = nil
358) -> String {
359
360 var protectedFragments: [String: String] = [:]
361
362 // Code spans render their contents literally, so they have to be taken out of
363 // the text before any later pass can treat those contents as markup — the tag
364 // pass below would otherwise promote an allowlisted `<b>` into a live tag.
365 var result = protectMatches(
366 in: text,
367 pattern: #"`([^`]+)`"#,
368 protectedFragments: &protectedFragments
369 ) { match, nsText in
370 let code = nsText.substring(with: match.range(at: 1))
371 return "<code>\(escapeHTML(code))</code>"
372 }
373
374 result = protectMatches(
375 in: result,
376 pattern: #"</?[A-Za-z][^>]*?>"#,
377 protectedFragments: &protectedFragments
378 ) { match, nsText in
379 let rawTag = nsText.substring(with: match.range)
380 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
381 }
382
383 result = escapeHTML(result)
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 = decodeHTMLEntities(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 = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
399 let resolvedURL = linkURLResolver?(rawURL) ?? rawURL
400 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else {
401 return label
402 }
403 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
404 }
405 // Plain email autolinks
406 result = replaceMatches(
407 in: result,
408 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
409 ) { match, nsText in
410 guard !isInsideHTMLTag(nsText, range: match.range) else {
411 return nsText.substring(with: match.range)
412 }
413 let email = nsText.substring(with: match.range(at: 1))
414 let href = escapeHTMLAttribute("mailto:\(email)")
415 return #"<a href="\#(href)">\#(email)</a>"#
416 }
417 // Strikethrough: ~~text~~
418 result = result.replacingOccurrences(
419 of: #"~~(.+?)~~"#,
420 with: "<del>$1</del>",
421 options: .regularExpression
422 )
423 // Bold: **text**
424 result = result.replacingOccurrences(
425 of: #"\*\*(.+?)\*\*"#,
426 with: "<strong>$1</strong>",
427 options: .regularExpression
428 )
429 // Italic: *text*
430 result = result.replacingOccurrences(
431 of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
432 with: "<em>$1</em>",
433 options: .regularExpression
434 )
435 // Italic: _text_
436 result = result.replacingOccurrences(
437 of: #"(?<!\w)_(.+?)_(?!\w)"#,
438 with: "<em>$1</em>",
439 options: .regularExpression
440 )
441 for (token, fragment) in protectedFragments {
442 result = result.replacingOccurrences(of: token, with: fragment)
443 }
444
445 return result
446}
447
448// MARK: - HTML Escaping
449
450nonisolated func escapeHTML(_ text: String) -> String {
451 text.replacingOccurrences(of: "&", with: "&")
452 .replacingOccurrences(of: "<", with: "<")
453 .replacingOccurrences(of: ">", with: ">")
454 .replacingOccurrences(of: "\"", with: """)
455}
456
457nonisolated func escapeHTMLAttribute(_ text: String) -> String {
458 escapeHTML(text).replacingOccurrences(of: "'", with: "'")
459}
460
461nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
462 sanitizeReadmeURLString(
463 rawURL,
464 allowedSchemes: ["http", "https", "mailto"],
465 allowsFragmentOnly: true
466 )
467}
468
469nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
470 sanitizeReadmeURLString(
471 rawURL,
472 allowedSchemes: ["http", "https"],
473 allowsFragmentOnly: false
474 )
475}
476
477nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
478 guard let scheme = url.scheme?.lowercased() else {
479 return false
480 }
481 if scheme == "about" {
482 return true
483 }
484 guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
485 return false
486 }
487 return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
488}
489
490nonisolated private func sanitizeReadmeURLString(
491 _ rawURL: String,
492 allowedSchemes: Set<String>,
493 allowsFragmentOnly: Bool
494) -> String? {
495 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
496 guard !trimmedURL.isEmpty else { return nil }
497
498 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
499 return escapeHTMLAttribute(trimmedURL)
500 }
501
502 guard let components = URLComponents(string: trimmedURL),
503 let scheme = components.scheme?.lowercased(),
504 allowedSchemes.contains(scheme),
505 let sanitizedURL = components.url?.absoluteString else {
506 return nil
507 }
508
509 return escapeHTMLAttribute(sanitizedURL)
510}
511
512nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
513 guard range.location != NSNotFound else { return false }
514 let prefix = text.substring(to: range.location)
515 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
516 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
517 return lastOpen > lastClose
518}
519
520private extension Array {
521 subscript(safe index: Int) -> Element? {
522 guard indices.contains(index) else { return nil }
523 return self[index]
524 }
525}
526
527
528nonisolated func decodeHTMLEntities(_ text: String) -> String {
529 text
530 .replacingOccurrences(of: "&", with: "&")
531 .replacingOccurrences(of: """, with: "\"")
532 .replacingOccurrences(of: "'", with: "'")
533 .replacingOccurrences(of: "<", with: "<")
534 .replacingOccurrences(of: ">", with: ">")
535}
536
537nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
538 var protectedFragments: [String: String] = [:]
539 var foundUnsafeMarkup = false
540 let protected = protectMatches(
541 in: rawHTML,
542 pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
543 protectedFragments: &protectedFragments
544 ) { match, nsText in
545 let rawTag = nsText.substring(with: match.range)
546 guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
547 foundUnsafeMarkup = true
548 return ""
549 }
550 return sanitizedTag
551 }
552
553 guard !foundUnsafeMarkup else { return nil }
554
555 var sanitized = escapeHTML(protected)
556 sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
557 let token = nsText.substring(with: match.range)
558 return protectedFragments[token] ?? ""
559 }
560
561 return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
562}
563
564nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
565 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
566 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
567 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
568
569 let selfClosing = trimmed.hasSuffix("/>")
570 let contentStart = trimmed.index(after: trimmed.startIndex)
571 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
572 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
573 let isClosing = inner.hasPrefix("/")
574 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
575 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
576 guard let rawName = parts.first else { return nil }
577 let tagName = rawName.lowercased()
578 let allowedTags: Set<String> = [
579 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
580 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
581 "sup", "u", "ul"
582 ]
583 guard allowedTags.contains(tagName) else { return nil }
584
585 if isClosing {
586 return "</\(tagName)>"
587 }
588
589 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
590 let attributes = parseHTMLAttributes(attributePortion)
591 var renderedAttributes: [String] = []
592
593 for (name, value) in attributes {
594 switch (tagName, name.lowercased()) {
595 case ("a", "href"):
596 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
597 renderedAttributes.append(#"href="\#(sanitized)""#)
598 }
599 case ("img", "src"):
600 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
601 renderedAttributes.append(#"src="\#(sanitized)""#)
602 }
603 case ("img", "alt"), (_, "title"), (_, "class"):
604 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
605 default:
606 continue
607 }
608 }
609
610 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
611 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
612 return "<\(tagName)\(attributeText)\(suffix)>"
613}
614
615nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
616 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
617 return []
618 }
619 let nsText = text as NSString
620 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
621 let name = nsText.substring(with: match.range(at: 1))
622 let value = nsText.substring(with: match.range(at: 2))
623 return (name, value)
624 }
625}
626
627nonisolated private func protectMatches(
628 in text: String,
629 pattern: String,
630 protectedFragments: inout [String: String],
631 transform: (NSTextCheckingResult, NSString) -> String
632) -> String {
633 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
634 var result = text
635 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
636
637 for match in matches.reversed() {
638 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
639 let nsText = result as NSString
640 protectedFragments[token] = transform(match, nsText)
641 result = nsText.replacingCharacters(in: match.range, with: token)
642 }
643
644 return result
645}
646
647nonisolated private func replaceMatches(
648 in text: String,
649 pattern: String,
650 transform: (NSTextCheckingResult, NSString) -> String
651) -> String {
652 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
653 var result = text
654 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
655
656 for match in matches.reversed() {
657 let nsText = result as NSString
658 let replacement = transform(match, nsText)
659 result = nsText.replacingCharacters(in: match.range, with: replacement)
660 }
661
662 return result
663}
664
665// MARK: - WKWebView Wrapper
666
667/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
668struct HTMLWebView: View {
669 let html: String
670 let colorScheme: ColorScheme
671 var style: HTMLWebViewStyle = .readme
672 var baseURL: URL? = nil
673 var onInterceptURL: ((URL) -> Bool)? = nil
674 @Environment(\.openURL) private var openURL
675 @State private var contentHeight: CGFloat = 1
676 @State private var loadError: String?
677 @State private var reloadToken = 0
678
679 var body: some View {
680 Group {
681 if let loadError {
682 SRHTErrorStateView(
683 title: "Couldn't Render Content",
684 message: loadError,
685 retryAction: {
686 await MainActor.run {
687 self.loadError = nil
688 reloadToken += 1
689 }
690 }
691 )
692 } else {
693 HTMLWebViewRepresentable(
694 html: html,
695 colorScheme: colorScheme,
696 style: style,
697 baseURL: baseURL,
698 onInterceptURL: onInterceptURL,
699 openURL: openURL,
700 dynamicHeight: $contentHeight,
701 loadError: $loadError,
702 reloadToken: reloadToken
703 )
704 .frame(height: max(contentHeight, 1))
705 }
706 }
707 }
708}
709
710struct HTMLWebViewStyle: Sendable {
711 let bodyFontSize: Int
712 let lineHeight: Double
713 let codeFontSize: Int
714 let viewport: String
715
716 static let readme = HTMLWebViewStyle(
717 bodyFontSize: 16,
718 lineHeight: 1.6,
719 codeFontSize: 13,
720 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
721 )
722
723 static let commentPreview = HTMLWebViewStyle(
724 bodyFontSize: 15,
725 lineHeight: 1.5,
726 codeFontSize: 12,
727 viewport: "width=device-width, initial-scale=1, user-scalable=no"
728 )
729}
730
731private struct HTMLWebViewRepresentable: UIViewRepresentable {
732 let html: String
733 let colorScheme: ColorScheme
734 let style: HTMLWebViewStyle
735 let baseURL: URL?
736 let onInterceptURL: ((URL) -> Bool)?
737 let openURL: OpenURLAction
738 @Binding var dynamicHeight: CGFloat
739 @Binding var loadError: String?
740 let reloadToken: Int
741
742 func makeCoordinator() -> HTMLWebViewCoordinator {
743 HTMLWebViewCoordinator(parent: self)
744 }
745
746 func makeUIView(context: Context) -> WKWebView {
747 let config = WKWebViewConfiguration()
748 config.defaultWebpagePreferences.allowsContentJavaScript = false
749 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
750 let webView = WKWebView(frame: .zero, configuration: config)
751 webView.isOpaque = false
752 webView.backgroundColor = .clear
753 webView.clipsToBounds = false
754 webView.allowsLinkPreview = false
755 webView.scrollView.isScrollEnabled = false
756 webView.scrollView.contentInsetAdjustmentBehavior = .never
757 webView.scrollView.clipsToBounds = false
758 webView.navigationDelegate = context.coordinator
759 return webView
760 }
761
762 func updateUIView(_ webView: WKWebView, context: Context) {
763 context.coordinator.parent = self
764 let textColor = colorScheme == .dark ? "#fff" : "#000"
765 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
766
767 let wrapped = """
768 <!DOCTYPE html>
769 <html>
770 <head>
771 <meta name="viewport" content="\(style.viewport)">
772 <style>
773 body {
774 font-family: -apple-system, system-ui, sans-serif;
775 font-size: \(style.bodyFontSize)px;
776 line-height: \(style.lineHeight);
777 padding: 0;
778 margin: 0;
779 color: \(textColor);
780 background: transparent;
781 word-wrap: break-word;
782 overflow-wrap: break-word;
783 max-width: 100%;
784 }
785 * { box-sizing: border-box; }
786 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
787 p:first-child { margin-top: 0; }
788 p:last-child { margin-bottom: 0; }
789 pre, code {
790 font-family: ui-monospace, Menlo, monospace;
791 font-size: \(style.codeFontSize)px;
792 background: rgba(128, 128, 128, 0.15);
793 padding: 2px 4px;
794 border-radius: 3px;
795 }
796 pre code { padding: 0; background: none; }
797 pre {
798 padding: 8px;
799 overflow-x: auto;
800 white-space: pre;
801 word-break: normal;
802 overflow-wrap: normal;
803 }
804 img { max-width: 100%; height: auto; }
805 svg {
806 max-width: 100%;
807 height: auto;
808 }
809 input[type="checkbox"] {
810 margin-right: 0.45rem;
811 vertical-align: middle;
812 }
813 .task-list-item {
814 display: inline;
815 }
816 a { color: \(linkColor); }
817 table { border-collapse: collapse; width: 100%; }
818 td, th { border: 1px solid #ccc; padding: 4px 8px; }
819 blockquote {
820 border-left: 3px solid rgba(128, 128, 128, 0.5);
821 margin: 0.5em 0;
822 padding: 0.25em 0 0.25em 1em;
823 color: inherit;
824 opacity: 0.85;
825 }
826 .org-verse {
827 white-space: pre-wrap;
828 }
829 hr {
830 border: none;
831 border-top: 1px solid rgba(128, 128, 128, 0.35);
832 margin: 1em 0;
833 }
834 table {
835 border-collapse: collapse;
836 width: 100%;
837 margin: 0.75em 0;
838 font-size: 0.95em;
839 }
840 th {
841 background: rgba(128, 128, 128, 0.15);
842 font-weight: 600;
843 text-align: left;
844 }
845 td, th {
846 border: 1px solid rgba(128, 128, 128, 0.3);
847 padding: 6px 10px;
848 }
849 dl.org-properties {
850 margin: 0.5em 0;
851 display: grid;
852 grid-template-columns: max-content 1fr;
853 gap: 2px 12px;
854 }
855 dt {
856 font-weight: 600;
857 font-family: ui-monospace, Menlo, monospace;
858 font-size: 0.9em;
859 }
860 dd { margin: 0; }
861 .org-metadata { margin-bottom: 1em; }
862 figure.org-block {
863 margin: 0.75em 0;
864 }
865 figure.org-block figcaption {
866 margin-top: 0.4em;
867 color: rgba(128, 128, 128, 0.85);
868 font-size: 0.9em;
869 }
870 .btn {
871 display: inline-flex;
872 align-items: center;
873 gap: 0.4em;
874 }
875 .icon {
876 display: inline-flex;
877 align-items: center;
878 vertical-align: middle;
879 }
880 .icon svg {
881 width: 0.65em;
882 height: 0.65em;
883 display: block;
884 fill: currentColor;
885 }
886 .org-title { margin: 0 0 0.25em; }
887 .org-author, .org-date {
888 margin: 0;
889 color: rgba(128, 128, 128, 0.85);
890 font-size: 0.9em;
891 }
892 del { opacity: 0.7; }
893 </style>
894 </head>
895 <body>\(html)</body>
896 </html>
897 """
898
899 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
900 let height = CGFloat(cachedHeight)
901 if abs(dynamicHeight - height) > 0.5 {
902 DispatchQueue.main.async {
903 if abs(self.dynamicHeight - height) > 0.5 {
904 self.dynamicHeight = height
905 }
906 }
907 }
908 }
909
910 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
911 context.coordinator.lastHTML = wrapped
912 context.coordinator.lastReloadToken = reloadToken
913 if loadError != nil {
914 DispatchQueue.main.async {
915 self.loadError = nil
916 }
917 }
918 webView.loadHTMLString(wrapped, baseURL: baseURL)
919 }
920}
921
922private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
923 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
924 static let heightCache = NSCache<NSString, NSNumber>()
925
926 var parent: HTMLWebViewRepresentable
927 var lastHTML: String?
928 var lastReloadToken = 0
929
930 init(parent: HTMLWebViewRepresentable) {
931 self.parent = parent
932 }
933
934 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
935 DispatchQueue.main.async {
936 self.parent.loadError = nil
937 }
938 updateHeight(for: webView)
939 }
940
941 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
942 handleLoadFailure(error)
943 }
944
945 func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
946 handleLoadFailure(error)
947 }
948
949 func webView(
950 _: WKWebView,
951 decidePolicyFor navigationAction: WKNavigationAction,
952 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
953 ) {
954 guard let requestURL = navigationAction.request.url else {
955 decisionHandler(.allow)
956 return
957 }
958
959 if navigationAction.navigationType == .linkActivated {
960 if isSameDocumentFragmentNavigation(requestURL) {
961 decisionHandler(.allow)
962 return
963 }
964 if let intercept = parent.onInterceptURL, intercept(requestURL) {
965 decisionHandler(.cancel)
966 return
967 }
968 if isAllowedReadmeNavigationURL(requestURL) {
969 parent.openURL(requestURL)
970 }
971 decisionHandler(.cancel)
972 return
973 }
974
975 if isAllowedReadmeNavigationURL(requestURL) {
976 decisionHandler(.allow)
977 } else {
978 decisionHandler(.cancel)
979 }
980 }
981
982 private func handleLoadFailure(_ error: Error) {
983 let nsError = error as NSError
984 guard nsError.code != NSURLErrorCancelled else { return }
985 DispatchQueue.main.async {
986 self.parent.loadError = "The content could not be displayed right now."
987 }
988 }
989
990 private func updateHeight(for webView: WKWebView) {
991 webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
992 guard let self else { return }
993 guard let heightValue = result as? NSNumber else { return }
994 let height = CGFloat(heightValue.doubleValue)
995 guard height > 0 else { return }
996 let rounded = ceil(height) + 4
997 DispatchQueue.main.async {
998 if let html = self.lastHTML {
999 Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1000 }
1001 if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1002 self.parent.dynamicHeight = rounded
1003 }
1004 }
1005 }
1006 }
1007
1008 private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
1009 guard url.fragment != nil,
1010 let baseURL = parent.baseURL else {
1011 return false
1012 }
1013
1014 guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
1015 var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
1016 return false
1017 }
1018
1019 destination.fragment = nil
1020 base.fragment = nil
1021 return destination.url == base.url
1022 }
1023}