Hutch/Views/Repositories/ReadmeView.swift
1118 lines · 40470 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: orgOptions,
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 html = await Task.detached(priority: .userInitiated) {
301 markdownToHTML(
302 text,
303 codeTheme: theme,
304 imageURLResolver: { source in
305 resolveRepositoryAssetURL(
306 source,
307 owner: ownerCanonicalName,
308 repositoryName: repositoryName,
309 readmePath: readmePath
310 )?
311 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
312 },
313 linkURLResolver: { source in
314 resolveRepositoryLinkURL(
315 source,
316 owner: ownerCanonicalName,
317 repositoryName: repositoryName,
318 readmePath: readmePath
319 )?
320 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
321 }
322 )
323 }.value
324 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
325 guard !Task.isCancelled else { return }
326 renderedHTML = html
327 }
328 }
329
330 /// Same resolution the HTML renderer used, so relative links and images
331 /// still point at this repository on this instance.
332 private var orgOptions: OrgRenderOptions {
333 OrgRenderOptions(
334 host: repositoryHost,
335 owner: ownerCanonicalName,
336 repositoryName: repositoryName,
337 ref: "HEAD",
338 readmePath: readmePath
339 )
340 }
341}
342
343private final class RenderedReadmeHTMLCache: @unchecked Sendable {
344 static let shared = RenderedReadmeHTMLCache()
345
346 private let storage = NSCache<NSString, NSString>()
347
348 func html(forKey key: String) -> String? {
349 storage.object(forKey: key as NSString) as String?
350 }
351
352 func setHTML(_ html: String, forKey key: String) {
353 storage.setObject(html as NSString, forKey: key as NSString)
354 }
355
356 func removeAll() {
357 storage.removeAllObjects()
358 }
359}
360
361@MainActor
362func clearWebContentRenderCaches() {
363 RenderedReadmeHTMLCache.shared.removeAll()
364 HTMLWebViewCoordinator.heightCache.removeAllObjects()
365}
366
367// MARK: - Markdown to HTML
368
369nonisolated func processInline(
370 _ text: String,
371 imageURLResolver: ((String) -> String?)? = nil,
372 linkURLResolver: ((String) -> String?)? = nil
373) -> String {
374
375 var protectedFragments: [String: String] = [:]
376
377 // Code spans render their contents literally, so they have to be taken out of
378 // the text before any later pass can treat those contents as markup — the tag
379 // pass below would otherwise promote an allowlisted `<b>` into a live tag.
380 var result = protectMatches(
381 in: text,
382 pattern: #"`([^`]+)`"#,
383 protectedFragments: &protectedFragments
384 ) { match, nsText in
385 let code = nsText.substring(with: match.range(at: 1))
386 return "<code>\(escapeHTML(code))</code>"
387 }
388
389 result = protectMatches(
390 in: result,
391 pattern: #"</?[A-Za-z][^>]*?>"#,
392 protectedFragments: &protectedFragments
393 ) { match, nsText in
394 let rawTag = nsText.substring(with: match.range)
395 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
396 }
397
398 result = escapeHTML(result)
399
400 // Images: 
401 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
402 let alt = nsText.substring(with: match.range(at: 1))
403 let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
404 let resolvedSource = imageURLResolver?(source) ?? source
405 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
406 return escapeHTML(alt)
407 }
408 return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
409 }
410 // Links: [text](url)
411 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
412 let label = nsText.substring(with: match.range(at: 1))
413 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
414 let resolvedURL = linkURLResolver?(rawURL) ?? rawURL
415 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else {
416 return label
417 }
418 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
419 }
420 // Plain email autolinks
421 result = replaceMatches(
422 in: result,
423 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
424 ) { match, nsText in
425 guard !isInsideHTMLTag(nsText, range: match.range) else {
426 return nsText.substring(with: match.range)
427 }
428 let email = nsText.substring(with: match.range(at: 1))
429 let href = escapeHTMLAttribute("mailto:\(email)")
430 return #"<a href="\#(href)">\#(email)</a>"#
431 }
432 // Strikethrough: ~~text~~
433 result = result.replacingOccurrences(
434 of: #"~~(.+?)~~"#,
435 with: "<del>$1</del>",
436 options: .regularExpression
437 )
438 // Bold: **text**
439 result = result.replacingOccurrences(
440 of: #"\*\*(.+?)\*\*"#,
441 with: "<strong>$1</strong>",
442 options: .regularExpression
443 )
444 // Italic: *text*
445 result = result.replacingOccurrences(
446 of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
447 with: "<em>$1</em>",
448 options: .regularExpression
449 )
450 // Italic: _text_
451 result = result.replacingOccurrences(
452 of: #"(?<!\w)_(.+?)_(?!\w)"#,
453 with: "<em>$1</em>",
454 options: .regularExpression
455 )
456 for (token, fragment) in protectedFragments {
457 result = result.replacingOccurrences(of: token, with: fragment)
458 }
459
460 return result
461}
462
463// MARK: - HTML Escaping
464
465nonisolated func escapeHTML(_ text: String) -> String {
466 text.replacingOccurrences(of: "&", with: "&")
467 .replacingOccurrences(of: "<", with: "<")
468 .replacingOccurrences(of: ">", with: ">")
469 .replacingOccurrences(of: "\"", with: """)
470}
471
472nonisolated func escapeHTMLAttribute(_ text: String) -> String {
473 escapeHTML(text).replacingOccurrences(of: "'", with: "'")
474}
475
476nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
477 sanitizeReadmeURLString(
478 rawURL,
479 allowedSchemes: ["http", "https", "mailto"],
480 allowsFragmentOnly: true
481 )
482}
483
484nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
485 sanitizeReadmeURLString(
486 rawURL,
487 allowedSchemes: ["http", "https"],
488 allowsFragmentOnly: false
489 )
490}
491
492nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
493 guard let scheme = url.scheme?.lowercased() else {
494 return false
495 }
496 if scheme == "about" {
497 return true
498 }
499 guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
500 return false
501 }
502 return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
503}
504
505nonisolated private func sanitizeReadmeURLString(
506 _ rawURL: String,
507 allowedSchemes: Set<String>,
508 allowsFragmentOnly: Bool
509) -> String? {
510 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
511 guard !trimmedURL.isEmpty else { return nil }
512
513 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
514 return escapeHTMLAttribute(trimmedURL)
515 }
516
517 guard let components = URLComponents(string: trimmedURL),
518 let scheme = components.scheme?.lowercased(),
519 allowedSchemes.contains(scheme),
520 let sanitizedURL = components.url?.absoluteString else {
521 return nil
522 }
523
524 return escapeHTMLAttribute(sanitizedURL)
525}
526
527nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
528 guard range.location != NSNotFound else { return false }
529 let prefix = text.substring(to: range.location)
530 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
531 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
532 return lastOpen > lastClose
533}
534
535private extension Array {
536 subscript(safe index: Int) -> Element? {
537 guard indices.contains(index) else { return nil }
538 return self[index]
539 }
540}
541
542
543nonisolated func decodeHTMLEntities(_ text: String) -> String {
544 text
545 .replacingOccurrences(of: "&", with: "&")
546 .replacingOccurrences(of: """, with: "\"")
547 .replacingOccurrences(of: "'", with: "'")
548 .replacingOccurrences(of: "<", with: "<")
549 .replacingOccurrences(of: ">", with: ">")
550}
551
552nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
553 var protectedFragments: [String: String] = [:]
554 var foundUnsafeMarkup = false
555 let protected = protectMatches(
556 in: rawHTML,
557 pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
558 protectedFragments: &protectedFragments
559 ) { match, nsText in
560 let rawTag = nsText.substring(with: match.range)
561 guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
562 foundUnsafeMarkup = true
563 return ""
564 }
565 return sanitizedTag
566 }
567
568 guard !foundUnsafeMarkup else { return nil }
569
570 var sanitized = escapeHTML(protected)
571 sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
572 let token = nsText.substring(with: match.range)
573 return protectedFragments[token] ?? ""
574 }
575
576 return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
577}
578
579nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
580 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
581 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
582 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
583
584 let selfClosing = trimmed.hasSuffix("/>")
585 let contentStart = trimmed.index(after: trimmed.startIndex)
586 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
587 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
588 let isClosing = inner.hasPrefix("/")
589 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
590 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
591 guard let rawName = parts.first else { return nil }
592 let tagName = rawName.lowercased()
593 let allowedTags: Set<String> = [
594 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
595 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
596 "sup", "u", "ul"
597 ]
598 guard allowedTags.contains(tagName) else { return nil }
599
600 if isClosing {
601 return "</\(tagName)>"
602 }
603
604 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
605 let attributes = parseHTMLAttributes(attributePortion)
606 var renderedAttributes: [String] = []
607
608 for (name, value) in attributes {
609 switch (tagName, name.lowercased()) {
610 case ("a", "href"):
611 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
612 renderedAttributes.append(#"href="\#(sanitized)""#)
613 }
614 case ("img", "src"):
615 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
616 renderedAttributes.append(#"src="\#(sanitized)""#)
617 }
618 case ("img", "alt"), (_, "title"), (_, "class"):
619 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
620 default:
621 continue
622 }
623 }
624
625 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
626 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
627 return "<\(tagName)\(attributeText)\(suffix)>"
628}
629
630nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
631 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
632 return []
633 }
634 let nsText = text as NSString
635 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
636 let name = nsText.substring(with: match.range(at: 1))
637 let value = nsText.substring(with: match.range(at: 2))
638 return (name, value)
639 }
640}
641
642nonisolated private func protectMatches(
643 in text: String,
644 pattern: String,
645 protectedFragments: inout [String: String],
646 transform: (NSTextCheckingResult, NSString) -> String
647) -> String {
648 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
649 var result = text
650 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
651
652 for match in matches.reversed() {
653 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
654 let nsText = result as NSString
655 protectedFragments[token] = transform(match, nsText)
656 result = nsText.replacingCharacters(in: match.range, with: token)
657 }
658
659 return result
660}
661
662nonisolated private func replaceMatches(
663 in text: String,
664 pattern: String,
665 transform: (NSTextCheckingResult, NSString) -> String
666) -> String {
667 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
668 var result = text
669 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
670
671 for match in matches.reversed() {
672 let nsText = result as NSString
673 let replacement = transform(match, nsText)
674 result = nsText.replacingCharacters(in: match.range, with: replacement)
675 }
676
677 return result
678}
679
680nonisolated func resolveRepositoryLinkURL(
681 _ source: String,
682 owner: String,
683 repositoryName: String,
684 readmePath: String?
685) -> String? {
686 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
687 guard !trimmedSource.isEmpty else { return nil }
688
689 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
690 || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
691 return trimmedSource
692 }
693
694 return resolveRepositoryAssetURL(
695 trimmedSource,
696 owner: owner,
697 repositoryName: repositoryName,
698 readmePath: readmePath
699 )
700}
701
702nonisolated func resolveRepositoryAssetURL(
703 _ source: String,
704 owner: String,
705 repositoryName: String,
706 readmePath: String?
707) -> String? {
708 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
709 guard !trimmedSource.isEmpty else { return nil }
710
711 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
712 return trimmedSource
713 }
714
715 let relativePath: String
716 if trimmedSource.hasPrefix("/") {
717 relativePath = String(trimmedSource.dropFirst())
718 } else {
719 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
720 relativePath = normalizeRepositoryPath(
721 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
722 )
723 }
724
725 guard !relativePath.isEmpty else { return nil }
726 var components = URLComponents()
727 components.scheme = "https"
728 components.host = "git.sr.ht"
729 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
730 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
731 let encodedRelativePath = relativePath
732 .split(separator: "/", omittingEmptySubsequences: false)
733 .map { segment in
734 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
735 }
736 .joined(separator: "/")
737 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
738 return components.string
739}
740
741nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
742 var components: [String] = []
743
744 for part in path.split(separator: "/") {
745 switch part {
746 case ".":
747 continue
748 case "..":
749 if !components.isEmpty {
750 components.removeLast()
751 }
752 default:
753 components.append(String(part))
754 }
755 }
756
757 return components.joined(separator: "/")
758}
759
760// MARK: - WKWebView Wrapper
761
762/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
763struct HTMLWebView: View {
764 let html: String
765 let colorScheme: ColorScheme
766 var style: HTMLWebViewStyle = .readme
767 var baseURL: URL? = nil
768 var onInterceptURL: ((URL) -> Bool)? = nil
769 @Environment(\.openURL) private var openURL
770 @State private var contentHeight: CGFloat = 1
771 @State private var loadError: String?
772 @State private var reloadToken = 0
773
774 var body: some View {
775 Group {
776 if let loadError {
777 SRHTErrorStateView(
778 title: "Couldn't Render Content",
779 message: loadError,
780 retryAction: {
781 await MainActor.run {
782 self.loadError = nil
783 reloadToken += 1
784 }
785 }
786 )
787 } else {
788 HTMLWebViewRepresentable(
789 html: html,
790 colorScheme: colorScheme,
791 style: style,
792 baseURL: baseURL,
793 onInterceptURL: onInterceptURL,
794 openURL: openURL,
795 dynamicHeight: $contentHeight,
796 loadError: $loadError,
797 reloadToken: reloadToken
798 )
799 .frame(height: max(contentHeight, 1))
800 }
801 }
802 }
803}
804
805struct HTMLWebViewStyle: Sendable {
806 let bodyFontSize: Int
807 let lineHeight: Double
808 let codeFontSize: Int
809 let viewport: String
810
811 static let readme = HTMLWebViewStyle(
812 bodyFontSize: 16,
813 lineHeight: 1.6,
814 codeFontSize: 13,
815 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
816 )
817
818 static let commentPreview = HTMLWebViewStyle(
819 bodyFontSize: 15,
820 lineHeight: 1.5,
821 codeFontSize: 12,
822 viewport: "width=device-width, initial-scale=1, user-scalable=no"
823 )
824}
825
826private struct HTMLWebViewRepresentable: UIViewRepresentable {
827 let html: String
828 let colorScheme: ColorScheme
829 let style: HTMLWebViewStyle
830 let baseURL: URL?
831 let onInterceptURL: ((URL) -> Bool)?
832 let openURL: OpenURLAction
833 @Binding var dynamicHeight: CGFloat
834 @Binding var loadError: String?
835 let reloadToken: Int
836
837 func makeCoordinator() -> HTMLWebViewCoordinator {
838 HTMLWebViewCoordinator(parent: self)
839 }
840
841 func makeUIView(context: Context) -> WKWebView {
842 let config = WKWebViewConfiguration()
843 config.defaultWebpagePreferences.allowsContentJavaScript = false
844 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
845 let webView = WKWebView(frame: .zero, configuration: config)
846 webView.isOpaque = false
847 webView.backgroundColor = .clear
848 webView.clipsToBounds = false
849 webView.allowsLinkPreview = false
850 webView.scrollView.isScrollEnabled = false
851 webView.scrollView.contentInsetAdjustmentBehavior = .never
852 webView.scrollView.clipsToBounds = false
853 webView.navigationDelegate = context.coordinator
854 return webView
855 }
856
857 func updateUIView(_ webView: WKWebView, context: Context) {
858 context.coordinator.parent = self
859 let textColor = colorScheme == .dark ? "#fff" : "#000"
860 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
861
862 let wrapped = """
863 <!DOCTYPE html>
864 <html>
865 <head>
866 <meta name="viewport" content="\(style.viewport)">
867 <style>
868 body {
869 font-family: -apple-system, system-ui, sans-serif;
870 font-size: \(style.bodyFontSize)px;
871 line-height: \(style.lineHeight);
872 padding: 0;
873 margin: 0;
874 color: \(textColor);
875 background: transparent;
876 word-wrap: break-word;
877 overflow-wrap: break-word;
878 max-width: 100%;
879 }
880 * { box-sizing: border-box; }
881 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
882 p:first-child { margin-top: 0; }
883 p:last-child { margin-bottom: 0; }
884 pre, code {
885 font-family: ui-monospace, Menlo, monospace;
886 font-size: \(style.codeFontSize)px;
887 background: rgba(128, 128, 128, 0.15);
888 padding: 2px 4px;
889 border-radius: 3px;
890 }
891 pre code { padding: 0; background: none; }
892 pre {
893 padding: 8px;
894 overflow-x: auto;
895 white-space: pre;
896 word-break: normal;
897 overflow-wrap: normal;
898 }
899 img { max-width: 100%; height: auto; }
900 svg {
901 max-width: 100%;
902 height: auto;
903 }
904 input[type="checkbox"] {
905 margin-right: 0.45rem;
906 vertical-align: middle;
907 }
908 .task-list-item {
909 display: inline;
910 }
911 a { color: \(linkColor); }
912 table { border-collapse: collapse; width: 100%; }
913 td, th { border: 1px solid #ccc; padding: 4px 8px; }
914 blockquote {
915 border-left: 3px solid rgba(128, 128, 128, 0.5);
916 margin: 0.5em 0;
917 padding: 0.25em 0 0.25em 1em;
918 color: inherit;
919 opacity: 0.85;
920 }
921 .org-verse {
922 white-space: pre-wrap;
923 }
924 hr {
925 border: none;
926 border-top: 1px solid rgba(128, 128, 128, 0.35);
927 margin: 1em 0;
928 }
929 table {
930 border-collapse: collapse;
931 width: 100%;
932 margin: 0.75em 0;
933 font-size: 0.95em;
934 }
935 th {
936 background: rgba(128, 128, 128, 0.15);
937 font-weight: 600;
938 text-align: left;
939 }
940 td, th {
941 border: 1px solid rgba(128, 128, 128, 0.3);
942 padding: 6px 10px;
943 }
944 dl.org-properties {
945 margin: 0.5em 0;
946 display: grid;
947 grid-template-columns: max-content 1fr;
948 gap: 2px 12px;
949 }
950 dt {
951 font-weight: 600;
952 font-family: ui-monospace, Menlo, monospace;
953 font-size: 0.9em;
954 }
955 dd { margin: 0; }
956 .org-metadata { margin-bottom: 1em; }
957 figure.org-block {
958 margin: 0.75em 0;
959 }
960 figure.org-block figcaption {
961 margin-top: 0.4em;
962 color: rgba(128, 128, 128, 0.85);
963 font-size: 0.9em;
964 }
965 .btn {
966 display: inline-flex;
967 align-items: center;
968 gap: 0.4em;
969 }
970 .icon {
971 display: inline-flex;
972 align-items: center;
973 vertical-align: middle;
974 }
975 .icon svg {
976 width: 0.65em;
977 height: 0.65em;
978 display: block;
979 fill: currentColor;
980 }
981 .org-title { margin: 0 0 0.25em; }
982 .org-author, .org-date {
983 margin: 0;
984 color: rgba(128, 128, 128, 0.85);
985 font-size: 0.9em;
986 }
987 del { opacity: 0.7; }
988 </style>
989 </head>
990 <body>\(html)</body>
991 </html>
992 """
993
994 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
995 let height = CGFloat(cachedHeight)
996 if abs(dynamicHeight - height) > 0.5 {
997 DispatchQueue.main.async {
998 if abs(self.dynamicHeight - height) > 0.5 {
999 self.dynamicHeight = height
1000 }
1001 }
1002 }
1003 }
1004
1005 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1006 context.coordinator.lastHTML = wrapped
1007 context.coordinator.lastReloadToken = reloadToken
1008 if loadError != nil {
1009 DispatchQueue.main.async {
1010 self.loadError = nil
1011 }
1012 }
1013 webView.loadHTMLString(wrapped, baseURL: baseURL)
1014 }
1015}
1016
1017private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1018 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1019 static let heightCache = NSCache<NSString, NSNumber>()
1020
1021 var parent: HTMLWebViewRepresentable
1022 var lastHTML: String?
1023 var lastReloadToken = 0
1024
1025 init(parent: HTMLWebViewRepresentable) {
1026 self.parent = parent
1027 }
1028
1029 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1030 DispatchQueue.main.async {
1031 self.parent.loadError = nil
1032 }
1033 updateHeight(for: webView)
1034 }
1035
1036 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1037 handleLoadFailure(error)
1038 }
1039
1040 func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1041 handleLoadFailure(error)
1042 }
1043
1044 func webView(
1045 _: WKWebView,
1046 decidePolicyFor navigationAction: WKNavigationAction,
1047 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1048 ) {
1049 guard let requestURL = navigationAction.request.url else {
1050 decisionHandler(.allow)
1051 return
1052 }
1053
1054 if navigationAction.navigationType == .linkActivated {
1055 if isSameDocumentFragmentNavigation(requestURL) {
1056 decisionHandler(.allow)
1057 return
1058 }
1059 if let intercept = parent.onInterceptURL, intercept(requestURL) {
1060 decisionHandler(.cancel)
1061 return
1062 }
1063 if isAllowedReadmeNavigationURL(requestURL) {
1064 parent.openURL(requestURL)
1065 }
1066 decisionHandler(.cancel)
1067 return
1068 }
1069
1070 if isAllowedReadmeNavigationURL(requestURL) {
1071 decisionHandler(.allow)
1072 } else {
1073 decisionHandler(.cancel)
1074 }
1075 }
1076
1077 private func handleLoadFailure(_ error: Error) {
1078 let nsError = error as NSError
1079 guard nsError.code != NSURLErrorCancelled else { return }
1080 DispatchQueue.main.async {
1081 self.parent.loadError = "The content could not be displayed right now."
1082 }
1083 }
1084
1085 private func updateHeight(for webView: WKWebView) {
1086 webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1087 guard let self else { return }
1088 guard let heightValue = result as? NSNumber else { return }
1089 let height = CGFloat(heightValue.doubleValue)
1090 guard height > 0 else { return }
1091 let rounded = ceil(height) + 4
1092 DispatchQueue.main.async {
1093 if let html = self.lastHTML {
1094 Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1095 }
1096 if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1097 self.parent.dynamicHeight = rounded
1098 }
1099 }
1100 }
1101 }
1102
1103 private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
1104 guard url.fragment != nil,
1105 let baseURL = parent.baseURL else {
1106 return false
1107 }
1108
1109 guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
1110 var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
1111 return false
1112 }
1113
1114 destination.fragment = nil
1115 base.fragment = nil
1116 return destination.url == base.url
1117 }
1118}