import OrgSwift import OrgSwiftUI import SwiftUI import WebKit struct ReadmeView: View { @Environment(AppState.self) private var appState let viewModel: RepositoryDetailViewModel @Environment(\.colorScheme) private var colorScheme @State private var isShowingRepositoryDetails = false @State private var linkedFile: LinkedFileRequest? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { headerSection metadataSection repositoryDetailsSection latestChangeSection readmeSection if appState.isDebugModeEnabled { debugSection } } .padding() } .sheet(item: $linkedFile) { request in LinkedFileSheetView( rid: viewModel.repository.rid, service: viewModel.repository.service, client: appState.client, request: request ) } .task { async let readme: () = viewModel.loadReadme() async let commits: () = viewModel.loadCommits() async let refs: () = viewModel.loadReferences() _ = await (readme, commits, refs) } .navigationDestination(for: CommitSummary.self) { commit in CommitDetailView( commitSummary: commit, repository: viewModel.repository ) } } @ViewBuilder private var headerSection: some View { VStack(alignment: .leading, spacing: 6) { Text(viewModel.repository.owner.canonicalName) .font(.subheadline) .foregroundStyle(.secondary) Text(viewModel.repository.name) .font(.largeTitle.weight(.semibold)) if let description = viewModel.repository.description, !description.isEmpty { Text(description) .font(.body) } } } @ViewBuilder private var metadataSection: some View { VStack(alignment: .leading, spacing: 10) { if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) { SummaryMetadataRow( icon: "arrow.triangle.branch", title: branchLabel ) } if let readmePath = viewModel.readmePath { SummaryMetadataRow( icon: "doc.text", title: readmePath ) } } } private var repositoryDetailsSection: some View { DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { VStack(alignment: .leading, spacing: 12) { SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service)) SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true) } .padding(.top, 8) } label: { Text("Repository Details") .font(.subheadline.weight(.medium)) } } @ViewBuilder private var latestChangeSection: some View { VStack(alignment: .leading, spacing: 8) { if viewModel.isLoadingCommits && viewModel.commits.isEmpty { SRHTLoadingStateView(message: "Loading latest change…") .frame(maxWidth: .infinity) } else if let commit = viewModel.commits.first { NavigationLink(value: commit) { SummaryMetadataRow( icon: "arrow.trianglehead.clockwise", title: commit.title, subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)" ) .contentShape(Rectangle()) } .buttonStyle(.plain) } else if let error = viewModel.error, viewModel.commits.isEmpty { SRHTErrorStateView( title: "Couldn't Load Latest Change", message: error, retryAction: { await viewModel.loadCommits() } ) } else { ContentUnavailableView( "No Recent Commits", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", description: Text("This repository does not have any commit history yet.") ) } } } @ViewBuilder private var readmeSection: some View { if viewModel.isLoadingReadme { SRHTLoadingStateView(message: "Loading README…") } else if let content = viewModel.readmeContent { RenderedMarkupContentView( content: sharedReadmeContent(from: content), readmePath: viewModel.readmePath, colorScheme: colorScheme, ownerCanonicalName: viewModel.repository.owner.canonicalName, repositoryName: viewModel.repository.name, onInterceptURL: { url in guard let request = parseLinkedFileRequest(url) else { return false } linkedFile = request return true } ) } else if let error = viewModel.error, !viewModel.readmeLoaded { SRHTErrorStateView( title: "Couldn't Load README", message: error, retryAction: { await viewModel.loadReadme() } ) } else { ContentUnavailableView( "No README", systemImage: "doc.text", description: Text("This repository does not have a README file.") ) } } /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest` /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`. /// Returns `nil` for any other URL (external links, fragment links, etc.). private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? { let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht" guard let host = url.host, host == expectedHost else { return nil } // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file // → ["/", "~owner", "repo", "blob", "HEAD", "file"] let parts = url.pathComponents guard parts.count >= 6, parts[1] == viewModel.repository.owner.canonicalName, parts[2] == viewModel.repository.name, parts[3] == "blob" else { return nil } let revspec = parts[4] let path = parts[5...].joined(separator: "/") guard !path.isEmpty else { return nil } let fileName = parts.last ?? path return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName) } private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { switch content { case .html(let html): .html(html) case .markdown(let text): .markdown(text) case .org(let text): .org(text) case .plainText(let text): .plainText(text) } } private var debugSection: some View { DebugTextBlock( title: "Debug", content: """ repositoryId: \(viewModel.repository.id) rid: \(viewModel.repository.rid) service: \(viewModel.repository.service.rawValue) defaultBranch: \(viewModel.repository.defaultBranchName ?? "none") webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable") httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable") sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository)) readmePath: \(viewModel.readmePath ?? "none") commitsLoaded: \(viewModel.commits.count) branchesLoaded: \(viewModel.branches.count) tagsLoaded: \(viewModel.tags.count) """ ) } } enum RenderedMarkupContent: Sendable { case html(String) case markdown(String) case org(String) case plainText(String) } struct RenderedMarkupContentView: View { let content: RenderedMarkupContent let readmePath: String? let colorScheme: ColorScheme let ownerCanonicalName: String let repositoryName: String var repositoryHost = "git.sr.ht" var onInterceptURL: ((URL) -> Bool)? = nil @State private var renderedHTML: String? private var cacheKey: String { // Highlighted code colors are baked into the HTML, so the theme is part // of the cache identity — light and dark must not share an entry. let theme = colorScheme == .dark ? "dark" : "light" switch content { case .html(let html): return "html:\(readmePath ?? "custom"):\(html)" case .markdown(let text): return "markdown:\(theme):\(readmePath ?? ""):\(text)" case .org(let text): return "org:\(readmePath ?? ""):\(text)" case .plainText(let text): return "plain:\(readmePath ?? ""):\(text)" } } var body: some View { Group { switch content { case .html(let html): HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL) case .markdown: if let renderedHTML { HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL) } else { SRHTLoadingStateView(message: "Preparing README…") } case .org(let text): OrgView( text, options: orgOptions, styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)), // Matches how tickets are colored: pending amber, resolved green. keywords: OrgKeywordStyle(todo: .orange, done: .green) ) .environment(\.openURL, OpenURLAction { url in // Links to files in this repository open in-app, as they did // when the web view intercepted its own navigation. if onInterceptURL?(url) == true { return .handled } return .systemAction }) case .plainText(let text): Text(text) .font(.system(.body, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } } .task(id: cacheKey) { await prepareHTMLIfNeeded() } } private func prepareHTMLIfNeeded() async { switch content { case .html, .plainText, .org: renderedHTML = nil case .markdown(let text): if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { renderedHTML = cached return } let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { markdownToHTML( text, codeTheme: theme, imageURLResolver: { source in resolveRepositoryAssetURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) }, linkURLResolver: { source in resolveRepositoryLinkURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) } ) }.value RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) guard !Task.isCancelled else { return } renderedHTML = html } } /// Same resolution the HTML renderer used, so relative links and images /// still point at this repository on this instance. private var orgOptions: OrgRenderOptions { OrgRenderOptions( host: repositoryHost, owner: ownerCanonicalName, repositoryName: repositoryName, ref: "HEAD", readmePath: readmePath ) } } private final class RenderedReadmeHTMLCache: @unchecked Sendable { static let shared = RenderedReadmeHTMLCache() private let storage = NSCache() func html(forKey key: String) -> String? { storage.object(forKey: key as NSString) as String? } func setHTML(_ html: String, forKey key: String) { storage.setObject(html as NSString, forKey: key as NSString) } func removeAll() { storage.removeAllObjects() } } @MainActor func clearWebContentRenderCaches() { RenderedReadmeHTMLCache.shared.removeAll() HTMLWebViewCoordinator.heightCache.removeAllObjects() } // MARK: - Markdown to HTML nonisolated func processInline( _ text: String, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { var protectedFragments: [String: String] = [:] // Code spans render their contents literally, so they have to be taken out of // the text before any later pass can treat those contents as markup — the tag // pass below would otherwise promote an allowlisted `` into a live tag. var result = protectMatches( in: text, pattern: #"`([^`]+)`"#, protectedFragments: &protectedFragments ) { match, nsText in let code = nsText.substring(with: match.range(at: 1)) return "\(escapeHTML(code))" } result = protectMatches( in: result, pattern: #"]*?>"#, protectedFragments: &protectedFragments ) { match, nsText in let rawTag = nsText.substring(with: match.range) return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag) } result = escapeHTML(result) // Images: ![alt](url) result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in let alt = nsText.substring(with: match.range(at: 1)) let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) let resolvedSource = imageURLResolver?(source) ?? source guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return escapeHTML(alt) } return #"\#(escapeHTMLAttribute(alt))"# } // Links: [text](url) result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in let label = nsText.substring(with: match.range(at: 1)) let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) let resolvedURL = linkURLResolver?(rawURL) ?? rawURL guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else { return label } return #"\#(label)"# } // Plain email autolinks result = replaceMatches( in: result, pattern: #"(?i)(?\#(email)"# } // Strikethrough: ~~text~~ result = result.replacingOccurrences( of: #"~~(.+?)~~"#, with: "$1", options: .regularExpression ) // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, with: "$1", options: .regularExpression ) // Italic: *text* result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) // Italic: _text_ result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) for (token, fragment) in protectedFragments { result = result.replacingOccurrences(of: token, with: fragment) } return result } // MARK: - HTML Escaping nonisolated func escapeHTML(_ text: String) -> String { text.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } nonisolated func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https", "mailto"], allowsFragmentOnly: true ) } nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https"], allowsFragmentOnly: false ) } nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if scheme == "about" { return true } guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { return false } return sanitizedURL == escapeHTMLAttribute(url.absoluteString) } nonisolated private func sanitizeReadmeURLString( _ rawURL: String, allowedSchemes: Set, allowsFragmentOnly: Bool ) -> String? { let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedURL.isEmpty else { return nil } if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { return escapeHTMLAttribute(trimmedURL) } guard let components = URLComponents(string: trimmedURL), let scheme = components.scheme?.lowercased(), allowedSchemes.contains(scheme), let sanitizedURL = components.url?.absoluteString else { return nil } return escapeHTMLAttribute(sanitizedURL) } nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { guard range.location != NSNotFound else { return false } let prefix = text.substring(to: range.location) guard let lastOpen = prefix.lastIndex(of: "<") else { return false } guard let lastClose = prefix.lastIndex(of: ">") else { return true } return lastOpen > lastClose } private extension Array { subscript(safe index: Int) -> Element? { guard indices.contains(index) else { return nil } return self[index] } } nonisolated func decodeHTMLEntities(_ text: String) -> String { text .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: """, with: "\"") .replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") } nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? { var protectedFragments: [String: String] = [:] var foundUnsafeMarkup = false let protected = protectMatches( in: rawHTML, pattern: #"(?s)|]*?>"#, protectedFragments: &protectedFragments ) { match, nsText in let rawTag = nsText.substring(with: match.range) guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else { foundUnsafeMarkup = true return "" } return sanitizedTag } guard !foundUnsafeMarkup else { return nil } var sanitized = escapeHTML(protected) sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in let token = nsText.substring(with: match.range) return protectedFragments[token] ?? "" } return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized } nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? { let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil } guard !trimmed.lowercased().hasPrefix("