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: ![alt](url)
 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: "&amp;")
 467        .replacingOccurrences(of: "<", with: "&lt;")
 468        .replacingOccurrences(of: ">", with: "&gt;")
 469        .replacingOccurrences(of: "\"", with: "&quot;")
 470}
 471
 472nonisolated func escapeHTMLAttribute(_ text: String) -> String {
 473    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
 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: "&amp;", with: "&")
 546        .replacingOccurrences(of: "&quot;", with: "\"")
 547        .replacingOccurrences(of: "&#39;", with: "'")
 548        .replacingOccurrences(of: "&lt;", with: "<")
 549        .replacingOccurrences(of: "&gt;", 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}