import SwiftUI import WebKit struct ReadmeView: View { let viewModel: RepositoryDetailViewModel @Environment(\.colorScheme) private var colorScheme @State private var isShowingRepositoryDetails = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { headerSection metadataSection repositoryDetailsSection latestChangeSection readmeSection } .padding() } .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) { SummaryMetadataRow( icon: "arrow.triangle.branch", title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) ) 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: "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 ) } 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.") ) } } 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) } } } 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" @State private var renderedHTML: String? private var cacheKey: String { switch content { case .html(let html): "html:\(readmePath ?? "custom"):\(html)" case .markdown(let text): "markdown:\(readmePath ?? ""):\(text)" case .org(let text): "org:\(readmePath ?? ""):\(text)" case .plainText(let text): "plain:\(readmePath ?? ""):\(text)" } } var body: some View { Group { switch content { case .html(let html): HTMLWebView(html: html, colorScheme: colorScheme) case .markdown, .org: if let renderedHTML { HTMLWebView(html: renderedHTML, colorScheme: colorScheme) } else { SRHTLoadingStateView(message: "Preparing README…") } 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: renderedHTML = nil case .markdown(let text): if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { renderedHTML = cached return } let html = await Task.detached(priority: .userInitiated) { markdownToHTML(text) { source in resolveRepositoryAssetURL( 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 case .org(let text): if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { renderedHTML = cached return } let html = await Task.detached(priority: .userInitiated) { orgToHTML(text) { source in resolveRepositoryAssetURL( 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 } } } 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) -> String { var protectedFragments: [String: String] = [:] var result = protectMatches( in: text, 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))) guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) 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 ) // Inline code: `text` result = result.replacingOccurrences( of: #"`([^`]+)`"#, with: "$1", options: .regularExpression ) for (token, fragment) in protectedFragments { result = result.replacingOccurrences(of: token, with: fragment) } return result } // MARK: - Org-mode to HTML nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { let normalizedText = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var title: String? var author: String? var date: String? let lines = rawLines.filter { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else { return true } let keyword = String(keywordMatch.1).lowercased() let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces) switch keyword { case "title": title = value return false case "author": author = value return false case "date": date = value return false default: return true } } var html = "" var listType: OrgListType? var inQuoteBlock = false var inPropertyDrawer = false var srcLanguage: String? var inExampleBlock = false var inCenterBlock = false var currentListItemLines: [String] = [] var paragraph: [String] = [] var tableRows: [[String]] = [] var propertyRows: [(String, String)] = [] func flushParagraph() { if !paragraph.isEmpty { let normalizedParagraph = paragraph .map { $0.trimmingCharacters(in: .whitespaces) } .joined(separator: " ") html += "

" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "

\n" paragraph = [] } } func flushListItem() { guard !currentListItemLines.isEmpty else { return } let content = currentListItemLines .map { $0.trimmingCharacters(in: .whitespaces) } .joined(separator: " ") html += "
  • " + renderTaskListItem( content, inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } ) + "
  • \n" currentListItemLines = [] } func closeList() { flushListItem() switch listType { case .unordered: html += "\n" case .ordered: html += "\n" case nil: break } listType = nil } func flushTable() { guard !tableRows.isEmpty else { return } html += renderHTMLTable( rows: tableRows, inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } ) tableRows = [] } func flushPropertyDrawer() { guard !propertyRows.isEmpty else { return } html += "
    \n" for (key, value) in propertyRows { html += "
    " + escapeHTML(key) + "
    " html += "
    " + processOrgInline(value, imageURLResolver: imageURLResolver) + "
    \n" } html += "
    \n" propertyRows = [] } func closeQuoteBlock() { if inQuoteBlock { flushParagraph() html += "\n" inQuoteBlock = false } } func closeSourceBlock() { if srcLanguage != nil { html += "\n" srcLanguage = nil } } func closeExampleBlock() { if inExampleBlock { html += "\n" inExampleBlock = false } } func closeCenterBlock() { if inCenterBlock { flushParagraph() html += "\n" inCenterBlock = false } } func flushBlockState() { flushParagraph() closeList() flushTable() flushPropertyDrawer() } if title != nil || author != nil || date != nil { html += "
    \n" if let title { html += "

    " + escapeHTML(title) + "

    \n" } if let author { html += "

    " + escapeHTML(author) + "

    \n" } if let date { html += "

    " + escapeHTML(date) + "

    \n" } html += "
    \n" } for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if srcLanguage != nil { if trimmed.lowercased() == "#+end_src" { closeSourceBlock() } else { html += escapeHTML(line) + "\n" } continue } if inExampleBlock { if trimmed.lowercased() == "#+end_example" { closeExampleBlock() } else { html += escapeHTML(line) + "\n" } continue } if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { closeQuoteBlock() continue } if inCenterBlock { if trimmed.lowercased() == "#+end_center" { closeCenterBlock() } else if trimmed.isEmpty { flushParagraph() } else { paragraph.append(line) } continue } if trimmed == "#" || trimmed.hasPrefix("# ") { continue } if trimmed.lowercased().hasPrefix("#+begin_src") { closeQuoteBlock() flushBlockState() let language = trimmed .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) .dropFirst() .first .map(String.init)? .trimmingCharacters(in: .whitespacesAndNewlines) let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" html += "
    "
                srcLanguage = language ?? ""
                continue
            }
    
            if trimmed.lowercased() == "#+begin_example" {
                closeQuoteBlock()
                flushBlockState()
                html += "
    "
                inExampleBlock = true
                continue
            }
    
            if trimmed.lowercased() == "#+begin_quote" {
                flushBlockState()
                html += "
    \n" inQuoteBlock = true continue } if trimmed.lowercased() == "#+begin_center" { closeQuoteBlock() flushBlockState() html += "
    \n" inCenterBlock = true continue } if trimmed == ":PROPERTIES:" { closeQuoteBlock() flushBlockState() inPropertyDrawer = true continue } if trimmed == ":END:", inPropertyDrawer { flushPropertyDrawer() inPropertyDrawer = false continue } if inPropertyDrawer, trimmed.hasPrefix(":"), let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { let keyStart = trimmed.index(after: trimmed.startIndex) let key = String(trimmed[keyStart..\n" continue } // List items: - item if trimmed.hasPrefix("- ") { flushParagraph() flushPropertyDrawer() if listType != .unordered { closeList() html += "
      \n" listType = .unordered } flushListItem() currentListItemLines = [String(trimmed.dropFirst(2))] continue } if let orderedItem = orderedListItem(in: trimmed) { flushParagraph() flushPropertyDrawer() if listType != .ordered { closeList() html += "
        \n" listType = .ordered } flushListItem() currentListItemLines = [orderedItem] continue } if listType != nil && isIndentedContinuationLine(line) { currentListItemLines.append(trimmed) continue } // Blank line if trimmed.isEmpty { if inQuoteBlock { flushParagraph() } else { flushBlockState() } continue } // Regular text paragraph.append(line) } closeSourceBlock() closeExampleBlock() closeCenterBlock() closeQuoteBlock() flushBlockState() return html } nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { var result = escapeHTML(text) var protectedFragments: [String: String] = [:] result = protectMatches( in: result, pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, protectedFragments: &protectedFragments ) { match, nsText in let url = nsText.substring(with: match.range(at: 1)) let label = nsText.substring(with: match.range(at: 2)) if let imageHTML = makeOrgImageHTML( source: url, alt: label, imageURLResolver: imageURLResolver ) { return imageHTML } guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { return label } return #"\#(label)"# } result = protectMatches( in: result, pattern: #"\[\[([^\]]+)\]\]"#, protectedFragments: &protectedFragments ) { match, nsText in let url = nsText.substring(with: match.range(at: 1)) if let imageHTML = makeOrgImageHTML( source: url, alt: nil, imageURLResolver: imageURLResolver ) { return imageHTML } guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { return url } return #"\#(url)"# } result = protectMatches( in: result, pattern: #"(?\(codeText)" } result = protectMatches( in: result, pattern: #"(?\(value)" } result = protectMatches( in: result, pattern: #"(?\(value)" } // Bold: *text* result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) // Italic: /text/ result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) result = replaceMatches( in: result, pattern: #"(?i)(?\#(email)"# } 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" || scheme == "data" { 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 isTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } nonisolated private func parseTableRow(_ line: String) -> [String] { line .split(separator: "|", omittingEmptySubsequences: false) .dropFirst() .dropLast() .map { String($0).trimmingCharacters(in: .whitespaces) } } nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { let trimmed = cell.trimmingCharacters(in: .whitespaces) return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } } nonisolated private func renderHTMLTable( rows: [[String]], inlineRenderer: (String) -> String ) -> String { guard !rows.isEmpty else { return "" } let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell) let headerRow = rows.first ?? [] let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows var html = "\n" if hasHeaderSeparator { html += "" for cell in headerRow { html += "" } html += "\n" } html += "\n" for row in bodyRows { html += "" for cell in row { html += "" } html += "\n" } html += "\n" html += "
        " + inlineRenderer(cell) + "
        " + inlineRenderer(cell) + "
        \n" return html } private enum OrgListType: Equatable { case unordered case ordered } nonisolated private func orderedListItem(in line: String) -> String? { guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } return String(match.2) } nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) } nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool { guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } let range = NSRange(location: 0, length: (text as NSString).length) return regex.firstMatch(in: text, range: range) != nil } 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 } nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool { guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } guard let first = line.first else { return false } return first == " " || first == "\t" } 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("