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: ![alt](url)
 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: "&amp;")
 452        .replacingOccurrences(of: "<", with: "&lt;")
 453        .replacingOccurrences(of: ">", with: "&gt;")
 454        .replacingOccurrences(of: "\"", with: "&quot;")
 455}
 456
 457nonisolated func escapeHTMLAttribute(_ text: String) -> String {
 458    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
 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: "&amp;", with: "&")
 531        .replacingOccurrences(of: "&quot;", with: "\"")
 532        .replacingOccurrences(of: "&#39;", with: "'")
 533        .replacingOccurrences(of: "&lt;", with: "<")
 534        .replacingOccurrences(of: "&gt;", 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}