krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.5.0: Hutch/Views/Repositories/ReadmeView.swift · raw

   1import SwiftUI
   2import WebKit
   3
   4struct ReadmeView: View {
   5    let viewModel: RepositoryDetailViewModel
   6
   7    @Environment(\.colorScheme) private var colorScheme
   8    @State private var isShowingRepositoryDetails = false
   9
  10    var body: some View {
  11        ScrollView {
  12            VStack(alignment: .leading, spacing: 16) {
  13                headerSection
  14                metadataSection
  15                repositoryDetailsSection
  16                latestChangeSection
  17                readmeSection
  18            }
  19            .padding()
  20        }
  21        .task {
  22            async let readme: () = viewModel.loadReadme()
  23            async let commits: () = viewModel.loadCommits()
  24            async let refs: () = viewModel.loadReferences()
  25            _ = await (readme, commits, refs)
  26        }
  27        .navigationDestination(for: CommitSummary.self) { commit in
  28            CommitDetailView(
  29                commitSummary: commit,
  30                repository: viewModel.repository
  31            )
  32        }
  33    }
  34
  35    @ViewBuilder
  36    private var headerSection: some View {
  37        VStack(alignment: .leading, spacing: 6) {
  38            Text(viewModel.repository.owner.canonicalName)
  39                .font(.subheadline)
  40                .foregroundStyle(.secondary)
  41            Text(viewModel.repository.name)
  42                .font(.largeTitle.weight(.semibold))
  43            if let description = viewModel.repository.description, !description.isEmpty {
  44                Text(description)
  45                    .font(.body)
  46            }
  47        }
  48    }
  49
  50    @ViewBuilder
  51    private var metadataSection: some View {
  52        VStack(alignment: .leading, spacing: 10) {
  53            SummaryMetadataRow(
  54                icon: "arrow.triangle.branch",
  55                title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
  56            )
  57
  58            if let readmePath = viewModel.readmePath {
  59                SummaryMetadataRow(
  60                    icon: "doc.text",
  61                    title: readmePath
  62                )
  63            }
  64        }
  65    }
  66
  67    private var repositoryDetailsSection: some View {
  68        DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
  69            VStack(alignment: .leading, spacing: 12) {
  70                SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
  71                SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
  72                SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
  73                SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
  74            }
  75            .padding(.top, 8)
  76        } label: {
  77            Text("Repository Details")
  78                .font(.subheadline.weight(.medium))
  79        }
  80    }
  81
  82    @ViewBuilder
  83    private var latestChangeSection: some View {
  84        VStack(alignment: .leading, spacing: 8) {
  85            if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
  86                SRHTLoadingStateView(message: "Loading latest change…")
  87                    .frame(maxWidth: .infinity)
  88            } else if let commit = viewModel.commits.first {
  89                NavigationLink(value: commit) {
  90                    SummaryMetadataRow(
  91                        icon: "arrow.trianglehead.clockwise",
  92                        title: commit.title,
  93                        subtitle: "\(commit.shortId)\(commit.author.name) \(commit.author.time.relativeDescription)"
  94                    )
  95                    .contentShape(Rectangle())
  96                }
  97                .buttonStyle(.plain)
  98            } else if let error = viewModel.error, viewModel.commits.isEmpty {
  99                SRHTErrorStateView(
 100                    title: "Couldn't Load Latest Change",
 101                    message: error,
 102                    retryAction: { await viewModel.loadCommits() }
 103                )
 104            } else {
 105                ContentUnavailableView(
 106                    "No Recent Commits",
 107                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
 108                    description: Text("This repository does not have any commit history yet.")
 109                )
 110            }
 111        }
 112    }
 113
 114    @ViewBuilder
 115    private var readmeSection: some View {
 116        if viewModel.isLoadingReadme {
 117            SRHTLoadingStateView(message: "Loading README…")
 118        } else if let content = viewModel.readmeContent {
 119            RenderedMarkupContentView(
 120                content: sharedReadmeContent(from: content),
 121                readmePath: viewModel.readmePath,
 122                colorScheme: colorScheme,
 123                ownerCanonicalName: viewModel.repository.owner.canonicalName,
 124                repositoryName: viewModel.repository.name
 125            )
 126        } else if let error = viewModel.error, !viewModel.readmeLoaded {
 127            SRHTErrorStateView(
 128                title: "Couldn't Load README",
 129                message: error,
 130                retryAction: { await viewModel.loadReadme() }
 131            )
 132        } else {
 133            ContentUnavailableView(
 134                "No README",
 135                systemImage: "doc.text",
 136                description: Text("This repository does not have a README file.")
 137            )
 138        }
 139    }
 140
 141    private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
 142        switch content {
 143        case .html(let html):
 144            .html(html)
 145        case .markdown(let text):
 146            .markdown(text)
 147        case .org(let text):
 148            .org(text)
 149        case .plainText(let text):
 150            .plainText(text)
 151        }
 152    }
 153}
 154
 155enum RenderedMarkupContent: Sendable {
 156    case html(String)
 157    case markdown(String)
 158    case org(String)
 159    case plainText(String)
 160}
 161
 162struct RenderedMarkupContentView: View {
 163    let content: RenderedMarkupContent
 164    let readmePath: String?
 165    let colorScheme: ColorScheme
 166    let ownerCanonicalName: String
 167    let repositoryName: String
 168    var repositoryHost = "git.sr.ht"
 169
 170    @State private var renderedHTML: String?
 171
 172    private var cacheKey: String {
 173        switch content {
 174        case .html(let html):
 175            "html:\(readmePath ?? "custom"):\(html)"
 176        case .markdown(let text):
 177            "markdown:\(readmePath ?? ""):\(text)"
 178        case .org(let text):
 179            "org:\(readmePath ?? ""):\(text)"
 180        case .plainText(let text):
 181            "plain:\(readmePath ?? ""):\(text)"
 182        }
 183    }
 184
 185    var body: some View {
 186        Group {
 187            switch content {
 188            case .html(let html):
 189                HTMLWebView(html: html, colorScheme: colorScheme)
 190            case .markdown, .org:
 191                if let renderedHTML {
 192                    HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
 193                } else {
 194                    SRHTLoadingStateView(message: "Preparing README…")
 195                }
 196            case .plainText(let text):
 197                Text(text)
 198                    .font(.system(.body, design: .monospaced))
 199                    .frame(maxWidth: .infinity, alignment: .leading)
 200            }
 201        }
 202        .task(id: cacheKey) {
 203            await prepareHTMLIfNeeded()
 204        }
 205    }
 206
 207    private func prepareHTMLIfNeeded() async {
 208        switch content {
 209        case .html, .plainText:
 210            renderedHTML = nil
 211        case .markdown(let text):
 212            if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
 213                renderedHTML = cached
 214                return
 215            }
 216            let html = await Task.detached(priority: .userInitiated) {
 217                markdownToHTML(text) { source in
 218                    resolveRepositoryAssetURL(
 219                        source,
 220                        owner: ownerCanonicalName,
 221                        repositoryName: repositoryName,
 222                        readmePath: readmePath
 223                    )?
 224                    .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 225                }
 226            }.value
 227            RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
 228            guard !Task.isCancelled else { return }
 229            renderedHTML = html
 230        case .org(let text):
 231            if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
 232                renderedHTML = cached
 233                return
 234            }
 235            let html = await Task.detached(priority: .userInitiated) {
 236                orgToHTML(text) { source in
 237                    resolveRepositoryAssetURL(
 238                        source,
 239                        owner: ownerCanonicalName,
 240                        repositoryName: repositoryName,
 241                        readmePath: readmePath
 242                    )?
 243                    .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 244                }
 245            }.value
 246            RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
 247            guard !Task.isCancelled else { return }
 248            renderedHTML = html
 249        }
 250    }
 251}
 252
 253private final class RenderedReadmeHTMLCache: @unchecked Sendable {
 254    static let shared = RenderedReadmeHTMLCache()
 255
 256    private let storage = NSCache<NSString, NSString>()
 257
 258    func html(forKey key: String) -> String? {
 259        storage.object(forKey: key as NSString) as String?
 260    }
 261
 262    func setHTML(_ html: String, forKey key: String) {
 263        storage.setObject(html as NSString, forKey: key as NSString)
 264    }
 265
 266    func removeAll() {
 267        storage.removeAllObjects()
 268    }
 269}
 270
 271@MainActor
 272func clearWebContentRenderCaches() {
 273    RenderedReadmeHTMLCache.shared.removeAll()
 274    HTMLWebViewCoordinator.heightCache.removeAllObjects()
 275}
 276
 277// MARK: - Markdown to HTML
 278
 279nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 280
 281    var protectedFragments: [String: String] = [:]
 282    var result = protectMatches(
 283        in: text,
 284        pattern: #"</?[A-Za-z][^>]*?>"#,
 285        protectedFragments: &protectedFragments
 286    ) { match, nsText in
 287        let rawTag = nsText.substring(with: match.range)
 288        return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
 289    }
 290
 291    result = escapeHTML(result)
 292
 293    // Images: ![alt](url)
 294    result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
 295        let alt = nsText.substring(with: match.range(at: 1))
 296        let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
 297        let resolvedSource = imageURLResolver?(source) ?? source
 298        guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
 299            return escapeHTML(alt)
 300        }
 301        return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
 302    }
 303    // Links: [text](url)
 304    result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
 305        let label = nsText.substring(with: match.range(at: 1))
 306        let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
 307        guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
 308            return label
 309        }
 310        return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
 311    }
 312    // Plain email autolinks
 313    result = replaceMatches(
 314        in: result,
 315        pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
 316    ) { match, nsText in
 317        guard !isInsideHTMLTag(nsText, range: match.range) else {
 318            return nsText.substring(with: match.range)
 319        }
 320        let email = nsText.substring(with: match.range(at: 1))
 321        let href = escapeHTMLAttribute("mailto:\(email)")
 322        return #"<a href="\#(href)">\#(email)</a>"#
 323    }
 324    // Strikethrough: ~~text~~
 325    result = result.replacingOccurrences(
 326        of: #"~~(.+?)~~"#,
 327        with: "<del>$1</del>",
 328        options: .regularExpression
 329    )
 330    // Bold: **text**
 331    result = result.replacingOccurrences(
 332        of: #"\*\*(.+?)\*\*"#,
 333        with: "<strong>$1</strong>",
 334        options: .regularExpression
 335    )
 336    // Italic: *text*
 337    result = result.replacingOccurrences(
 338        of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
 339        with: "<em>$1</em>",
 340        options: .regularExpression
 341    )
 342    // Italic: _text_
 343    result = result.replacingOccurrences(
 344        of: #"(?<!\w)_(.+?)_(?!\w)"#,
 345        with: "<em>$1</em>",
 346        options: .regularExpression
 347    )
 348    // Inline code: `text`
 349    result = result.replacingOccurrences(
 350        of: #"`([^`]+)`"#,
 351        with: "<code>$1</code>",
 352        options: .regularExpression
 353    )
 354
 355    for (token, fragment) in protectedFragments {
 356        result = result.replacingOccurrences(of: token, with: fragment)
 357    }
 358
 359    return result
 360}
 361
 362// MARK: - Org-mode to HTML
 363
 364nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 365    let normalizedText = text
 366        .replacingOccurrences(of: "\r\n", with: "\n")
 367        .replacingOccurrences(of: "\r", with: "\n")
 368    let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
 369    var title: String?
 370    var author: String?
 371    var date: String?
 372    let lines = rawLines.filter { line in
 373        let trimmed = line.trimmingCharacters(in: .whitespaces)
 374        guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else {
 375            return true
 376        }
 377        let keyword = String(keywordMatch.1).lowercased()
 378        let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
 379        switch keyword {
 380        case "title":
 381            title = value
 382            return false
 383        case "author":
 384            author = value
 385            return false
 386        case "date":
 387            date = value
 388            return false
 389        default:
 390            return true
 391        }
 392    }
 393    var html = ""
 394    var listType: OrgListType?
 395    var inQuoteBlock = false
 396    var inPropertyDrawer = false
 397    var srcLanguage: String?
 398    var inExampleBlock = false
 399    var inCenterBlock = false
 400    var currentListItemLines: [String] = []
 401    var paragraph: [String] = []
 402    var tableRows: [[String]] = []
 403    var propertyRows: [(String, String)] = []
 404
 405    func flushParagraph() {
 406        if !paragraph.isEmpty {
 407            let normalizedParagraph = paragraph
 408                .map { $0.trimmingCharacters(in: .whitespaces) }
 409                .joined(separator: " ")
 410            html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
 411            paragraph = []
 412        }
 413    }
 414
 415    func flushListItem() {
 416        guard !currentListItemLines.isEmpty else { return }
 417        let content = currentListItemLines
 418            .map { $0.trimmingCharacters(in: .whitespaces) }
 419            .joined(separator: " ")
 420        html += "<li>" + renderTaskListItem(
 421            content,
 422            inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
 423        ) + "</li>\n"
 424        currentListItemLines = []
 425    }
 426
 427    func closeList() {
 428        flushListItem()
 429        switch listType {
 430        case .unordered:
 431            html += "</ul>\n"
 432        case .ordered:
 433            html += "</ol>\n"
 434        case nil:
 435            break
 436        }
 437        listType = nil
 438    }
 439
 440    func flushTable() {
 441        guard !tableRows.isEmpty else { return }
 442        html += renderHTMLTable(
 443            rows: tableRows,
 444            inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
 445        )
 446        tableRows = []
 447    }
 448
 449    func flushPropertyDrawer() {
 450        guard !propertyRows.isEmpty else { return }
 451        html += "<dl class=\"org-properties\">\n"
 452        for (key, value) in propertyRows {
 453            html += "<dt>" + escapeHTML(key) + "</dt>"
 454            html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
 455        }
 456        html += "</dl>\n"
 457        propertyRows = []
 458    }
 459
 460    func closeQuoteBlock() {
 461        if inQuoteBlock {
 462            flushParagraph()
 463            html += "</blockquote>\n"
 464            inQuoteBlock = false
 465        }
 466    }
 467
 468    func closeSourceBlock() {
 469        if srcLanguage != nil {
 470            html += "</code></pre>\n"
 471            srcLanguage = nil
 472        }
 473    }
 474
 475    func closeExampleBlock() {
 476        if inExampleBlock {
 477            html += "</code></pre>\n"
 478            inExampleBlock = false
 479        }
 480    }
 481
 482    func closeCenterBlock() {
 483        if inCenterBlock {
 484            flushParagraph()
 485            html += "</div>\n"
 486            inCenterBlock = false
 487        }
 488    }
 489
 490    func flushBlockState() {
 491        flushParagraph()
 492        closeList()
 493        flushTable()
 494        flushPropertyDrawer()
 495    }
 496
 497    if title != nil || author != nil || date != nil {
 498        html += "<div class=\"org-metadata\">\n"
 499        if let title {
 500            html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
 501        }
 502        if let author {
 503            html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
 504        }
 505        if let date {
 506            html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
 507        }
 508        html += "</div>\n"
 509    }
 510
 511    for line in lines {
 512        let trimmed = line.trimmingCharacters(in: .whitespaces)
 513
 514        if srcLanguage != nil {
 515            if trimmed.lowercased() == "#+end_src" {
 516                closeSourceBlock()
 517            } else {
 518                html += escapeHTML(line) + "\n"
 519            }
 520            continue
 521        }
 522
 523        if inExampleBlock {
 524            if trimmed.lowercased() == "#+end_example" {
 525                closeExampleBlock()
 526            } else {
 527                html += escapeHTML(line) + "\n"
 528            }
 529            continue
 530        }
 531
 532        if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
 533            closeQuoteBlock()
 534            continue
 535        }
 536
 537        if inCenterBlock {
 538            if trimmed.lowercased() == "#+end_center" {
 539                closeCenterBlock()
 540            } else if trimmed.isEmpty {
 541                flushParagraph()
 542            } else {
 543                paragraph.append(line)
 544            }
 545            continue
 546        }
 547
 548        if trimmed == "#" || trimmed.hasPrefix("# ") {
 549            continue
 550        }
 551
 552        if trimmed.lowercased().hasPrefix("#+begin_src") {
 553            closeQuoteBlock()
 554            flushBlockState()
 555            let language = trimmed
 556                .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
 557                .dropFirst()
 558                .first
 559                .map(String.init)?
 560                .trimmingCharacters(in: .whitespacesAndNewlines)
 561            let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
 562            html += "<pre><code\(classAttribute)>"
 563            srcLanguage = language ?? ""
 564            continue
 565        }
 566
 567        if trimmed.lowercased() == "#+begin_example" {
 568            closeQuoteBlock()
 569            flushBlockState()
 570            html += "<pre><code>"
 571            inExampleBlock = true
 572            continue
 573        }
 574
 575        if trimmed.lowercased() == "#+begin_quote" {
 576            flushBlockState()
 577            html += "<blockquote>\n"
 578            inQuoteBlock = true
 579            continue
 580        }
 581
 582        if trimmed.lowercased() == "#+begin_center" {
 583            closeQuoteBlock()
 584            flushBlockState()
 585            html += "<div style=\"text-align:center\">\n"
 586            inCenterBlock = true
 587            continue
 588        }
 589
 590        if trimmed == ":PROPERTIES:" {
 591            closeQuoteBlock()
 592            flushBlockState()
 593            inPropertyDrawer = true
 594            continue
 595        }
 596
 597        if trimmed == ":END:", inPropertyDrawer {
 598            flushPropertyDrawer()
 599            inPropertyDrawer = false
 600            continue
 601        }
 602
 603        if inPropertyDrawer,
 604           trimmed.hasPrefix(":"),
 605           let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
 606            let keyStart = trimmed.index(after: trimmed.startIndex)
 607            let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
 608            let valueStart = trimmed.index(after: secondColonIndex)
 609            let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
 610            if !key.isEmpty {
 611                propertyRows.append((key, value))
 612                continue
 613            }
 614        }
 615
 616        if isTableLine(trimmed) {
 617            closeQuoteBlock()
 618            flushParagraph()
 619            closeList()
 620            tableRows.append(parseTableRow(trimmed))
 621            continue
 622        } else {
 623            flushTable()
 624        }
 625
 626        if isOrgHorizontalRule(trimmed) {
 627            closeQuoteBlock()
 628            flushBlockState()
 629            html += "<hr>\n"
 630            continue
 631        }
 632
 633        // Org headings: * heading, ** heading, *** heading
 634        if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
 635            closeQuoteBlock()
 636            flushBlockState()
 637            let level = match.1.count
 638            let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
 639            html += "<h\(level)>" + content + "</h\(level)>\n"
 640            continue
 641        }
 642
 643        // List items: - item
 644        if trimmed.hasPrefix("- ") {
 645            flushParagraph()
 646            flushPropertyDrawer()
 647            if listType != .unordered {
 648                closeList()
 649                html += "<ul>\n"
 650                listType = .unordered
 651            }
 652            flushListItem()
 653            currentListItemLines = [String(trimmed.dropFirst(2))]
 654            continue
 655        }
 656
 657        if let orderedItem = orderedListItem(in: trimmed) {
 658            flushParagraph()
 659            flushPropertyDrawer()
 660            if listType != .ordered {
 661                closeList()
 662                html += "<ol>\n"
 663                listType = .ordered
 664            }
 665            flushListItem()
 666            currentListItemLines = [orderedItem]
 667            continue
 668        }
 669
 670        if listType != nil && isIndentedContinuationLine(line) {
 671            currentListItemLines.append(trimmed)
 672            continue
 673        }
 674
 675        // Blank line
 676        if trimmed.isEmpty {
 677            if inQuoteBlock {
 678                flushParagraph()
 679            } else {
 680                flushBlockState()
 681            }
 682            continue
 683        }
 684
 685        // Regular text
 686        paragraph.append(line)
 687    }
 688
 689    closeSourceBlock()
 690    closeExampleBlock()
 691    closeCenterBlock()
 692    closeQuoteBlock()
 693    flushBlockState()
 694
 695    return html
 696}
 697
 698nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 699    var result = escapeHTML(text)
 700    var protectedFragments: [String: String] = [:]
 701
 702    result = protectMatches(
 703        in: result,
 704        pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
 705        protectedFragments: &protectedFragments
 706    ) { match, nsText in
 707        let url = nsText.substring(with: match.range(at: 1))
 708        let label = nsText.substring(with: match.range(at: 2))
 709        if let imageHTML = makeOrgImageHTML(
 710            source: url,
 711            alt: label,
 712            imageURLResolver: imageURLResolver
 713        ) {
 714            return imageHTML
 715        }
 716        guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
 717            return label
 718        }
 719        return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
 720    }
 721    result = protectMatches(
 722        in: result,
 723        pattern: #"\[\[([^\]]+)\]\]"#,
 724        protectedFragments: &protectedFragments
 725    ) { match, nsText in
 726        let url = nsText.substring(with: match.range(at: 1))
 727        if let imageHTML = makeOrgImageHTML(
 728            source: url,
 729            alt: nil,
 730            imageURLResolver: imageURLResolver
 731        ) {
 732            return imageHTML
 733        }
 734        guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
 735            return url
 736        }
 737        return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
 738    }
 739    result = protectMatches(
 740        in: result,
 741        pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
 742        protectedFragments: &protectedFragments
 743    ) { match, nsText in
 744        let tildeRange = match.range(at: 1)
 745        let equalsRange = match.range(at: 2)
 746        let codeText: String
 747        if tildeRange.location != NSNotFound {
 748            codeText = nsText.substring(with: tildeRange)
 749        } else {
 750            codeText = nsText.substring(with: equalsRange)
 751        }
 752        return "<code>\(codeText)</code>"
 753    }
 754    result = protectMatches(
 755        in: result,
 756        pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
 757        protectedFragments: &protectedFragments
 758    ) { match, nsText in
 759        let value = nsText.substring(with: match.range(at: 1))
 760        return "<del>\(value)</del>"
 761    }
 762    result = protectMatches(
 763        in: result,
 764        pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
 765        protectedFragments: &protectedFragments
 766    ) { match, nsText in
 767        let value = nsText.substring(with: match.range(at: 1))
 768        return "<u>\(value)</u>"
 769    }
 770
 771    // Bold: *text*
 772    result = result.replacingOccurrences(
 773        of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
 774        with: "<strong>$1</strong>",
 775        options: .regularExpression
 776    )
 777    // Italic: /text/
 778    result = result.replacingOccurrences(
 779        of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
 780        with: "<em>$1</em>",
 781        options: .regularExpression
 782    )
 783    result = replaceMatches(
 784        in: result,
 785        pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
 786    ) { match, nsText in
 787        guard !isInsideHTMLTag(nsText, range: match.range) else {
 788            return nsText.substring(with: match.range)
 789        }
 790        let email = nsText.substring(with: match.range(at: 1))
 791        let href = escapeHTMLAttribute("mailto:\(email)")
 792        return #"<a href="\#(href)">\#(email)</a>"#
 793    }
 794
 795    for (token, fragment) in protectedFragments {
 796        result = result.replacingOccurrences(of: token, with: fragment)
 797    }
 798
 799    return result
 800}
 801
 802// MARK: - HTML Escaping
 803
 804nonisolated func escapeHTML(_ text: String) -> String {
 805    text.replacingOccurrences(of: "&", with: "&amp;")
 806        .replacingOccurrences(of: "<", with: "&lt;")
 807        .replacingOccurrences(of: ">", with: "&gt;")
 808        .replacingOccurrences(of: "\"", with: "&quot;")
 809}
 810
 811nonisolated func escapeHTMLAttribute(_ text: String) -> String {
 812    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
 813}
 814
 815nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
 816    sanitizeReadmeURLString(
 817        rawURL,
 818        allowedSchemes: ["http", "https", "mailto"],
 819        allowsFragmentOnly: true
 820    )
 821}
 822
 823nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
 824    sanitizeReadmeURLString(
 825        rawURL,
 826        allowedSchemes: ["http", "https"],
 827        allowsFragmentOnly: false
 828    )
 829}
 830
 831nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
 832    guard let scheme = url.scheme?.lowercased() else {
 833        return false
 834    }
 835    if scheme == "about" || scheme == "data" {
 836        return true
 837    }
 838    guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
 839        return false
 840    }
 841    return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
 842}
 843
 844nonisolated private func sanitizeReadmeURLString(
 845    _ rawURL: String,
 846    allowedSchemes: Set<String>,
 847    allowsFragmentOnly: Bool
 848) -> String? {
 849    let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
 850    guard !trimmedURL.isEmpty else { return nil }
 851
 852    if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
 853        return escapeHTMLAttribute(trimmedURL)
 854    }
 855
 856    guard let components = URLComponents(string: trimmedURL),
 857          let scheme = components.scheme?.lowercased(),
 858          allowedSchemes.contains(scheme),
 859          let sanitizedURL = components.url?.absoluteString else {
 860        return nil
 861    }
 862
 863    return escapeHTMLAttribute(sanitizedURL)
 864}
 865
 866nonisolated private func isTableLine(_ line: String) -> Bool {
 867    line.hasPrefix("|") && line.hasSuffix("|")
 868}
 869
 870nonisolated private func parseTableRow(_ line: String) -> [String] {
 871    line
 872        .split(separator: "|", omittingEmptySubsequences: false)
 873        .dropFirst()
 874        .dropLast()
 875        .map { String($0).trimmingCharacters(in: .whitespaces) }
 876}
 877
 878nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
 879    let trimmed = cell.trimmingCharacters(in: .whitespaces)
 880    return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
 881}
 882
 883nonisolated private func renderHTMLTable(
 884    rows: [[String]],
 885    inlineRenderer: (String) -> String
 886) -> String {
 887    guard !rows.isEmpty else { return "" }
 888    let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell)
 889    let headerRow = rows.first ?? []
 890    let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
 891    var html = "<table>\n"
 892
 893    if hasHeaderSeparator {
 894        html += "<thead><tr>"
 895        for cell in headerRow {
 896            html += "<th>" + inlineRenderer(cell) + "</th>"
 897        }
 898        html += "</tr></thead>\n"
 899    }
 900
 901    html += "<tbody>\n"
 902    for row in bodyRows {
 903        html += "<tr>"
 904        for cell in row {
 905            html += "<td>" + inlineRenderer(cell) + "</td>"
 906        }
 907        html += "</tr>\n"
 908    }
 909    html += "</tbody>\n"
 910    html += "</table>\n"
 911    return html
 912}
 913
 914private enum OrgListType: Equatable {
 915    case unordered
 916    case ordered
 917}
 918
 919nonisolated private func orderedListItem(in line: String) -> String? {
 920    guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
 921    return String(match.2)
 922}
 923
 924
 925nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
 926    matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
 927}
 928
 929nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
 930    guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
 931    let range = NSRange(location: 0, length: (text as NSString).length)
 932    return regex.firstMatch(in: text, range: range) != nil
 933}
 934
 935nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
 936    guard range.location != NSNotFound else { return false }
 937    let prefix = text.substring(to: range.location)
 938    guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
 939    guard let lastClose = prefix.lastIndex(of: ">") else { return true }
 940    return lastOpen > lastClose
 941}
 942
 943nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
 944    guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
 945    guard let first = line.first else { return false }
 946    return first == " " || first == "\t"
 947}
 948
 949
 950nonisolated func decodeHTMLEntities(_ text: String) -> String {
 951    text
 952        .replacingOccurrences(of: "&amp;", with: "&")
 953        .replacingOccurrences(of: "&quot;", with: "\"")
 954        .replacingOccurrences(of: "&#39;", with: "'")
 955        .replacingOccurrences(of: "&lt;", with: "<")
 956        .replacingOccurrences(of: "&gt;", with: ">")
 957}
 958
 959nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
 960    var protectedFragments: [String: String] = [:]
 961    var foundUnsafeMarkup = false
 962    let protected = protectMatches(
 963        in: rawHTML,
 964        pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
 965        protectedFragments: &protectedFragments
 966    ) { match, nsText in
 967        let rawTag = nsText.substring(with: match.range)
 968        guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
 969            foundUnsafeMarkup = true
 970            return ""
 971        }
 972        return sanitizedTag
 973    }
 974
 975    guard !foundUnsafeMarkup else { return nil }
 976
 977    var sanitized = escapeHTML(protected)
 978    sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
 979        let token = nsText.substring(with: match.range)
 980        return protectedFragments[token] ?? ""
 981    }
 982
 983    return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
 984}
 985
 986nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
 987    let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
 988    guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
 989    guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
 990
 991    let selfClosing = trimmed.hasSuffix("/>")
 992    let contentStart = trimmed.index(after: trimmed.startIndex)
 993    let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
 994    let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
 995    let isClosing = inner.hasPrefix("/")
 996    let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
 997    let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
 998    guard let rawName = parts.first else { return nil }
 999    let tagName = rawName.lowercased()
1000    let allowedTags: Set<String> = [
1001        "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1002        "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1003        "sup", "u", "ul"
1004    ]
1005    guard allowedTags.contains(tagName) else { return nil }
1006
1007    if isClosing {
1008        return "</\(tagName)>"
1009    }
1010
1011    let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1012    let attributes = parseHTMLAttributes(attributePortion)
1013    var renderedAttributes: [String] = []
1014
1015    for (name, value) in attributes {
1016        switch (tagName, name.lowercased()) {
1017        case ("a", "href"):
1018            if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1019                renderedAttributes.append(#"href="\#(sanitized)""#)
1020            }
1021        case ("img", "src"):
1022            if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1023                renderedAttributes.append(#"src="\#(sanitized)""#)
1024            }
1025        case ("img", "alt"), (_, "title"), (_, "class"):
1026            renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1027        default:
1028            continue
1029        }
1030    }
1031
1032    let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1033    let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1034    return "<\(tagName)\(attributeText)\(suffix)>"
1035}
1036
1037nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1038    guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1039        return []
1040    }
1041    let nsText = text as NSString
1042    return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1043        let name = nsText.substring(with: match.range(at: 1))
1044        let value = nsText.substring(with: match.range(at: 2))
1045        return (name, value)
1046    }
1047}
1048
1049nonisolated private func protectMatches(
1050    in text: String,
1051    pattern: String,
1052    protectedFragments: inout [String: String],
1053    transform: (NSTextCheckingResult, NSString) -> String
1054) -> String {
1055    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1056    var result = text
1057    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1058
1059    for match in matches.reversed() {
1060        let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1061        let nsText = result as NSString
1062        protectedFragments[token] = transform(match, nsText)
1063        result = nsText.replacingCharacters(in: match.range, with: token)
1064    }
1065
1066    return result
1067}
1068
1069nonisolated private func replaceMatches(
1070    in text: String,
1071    pattern: String,
1072    transform: (NSTextCheckingResult, NSString) -> String
1073) -> String {
1074    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1075    var result = text
1076    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1077
1078    for match in matches.reversed() {
1079        let nsText = result as NSString
1080        let replacement = transform(match, nsText)
1081        result = nsText.replacingCharacters(in: match.range, with: replacement)
1082    }
1083
1084    return result
1085}
1086
1087nonisolated private func makeOrgImageHTML(
1088    source: String,
1089    alt: String?,
1090    imageURLResolver: ((String) -> String?)?
1091) -> String? {
1092    guard isRenderableImageSource(source) else { return nil }
1093    let resolvedSource = imageURLResolver?(source) ?? source
1094    guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1095    let altText = escapeHTMLAttribute(alt ?? "")
1096    return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1097}
1098
1099nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1100    let lowercased = source.lowercased()
1101    return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1102        .contains(where: { lowercased.hasSuffix($0) })
1103}
1104
1105nonisolated func resolveRepositoryAssetURL(
1106    _ source: String,
1107    owner: String,
1108    repositoryName: String,
1109    readmePath: String?
1110) -> String? {
1111    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1112    guard !trimmedSource.isEmpty else { return nil }
1113
1114    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
1115        return trimmedSource
1116    }
1117
1118    let relativePath: String
1119    if trimmedSource.hasPrefix("/") {
1120        relativePath = String(trimmedSource.dropFirst())
1121    } else {
1122        let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
1123        relativePath = normalizeRepositoryPath(
1124            (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
1125        )
1126    }
1127
1128    guard !relativePath.isEmpty else { return nil }
1129    var components = URLComponents()
1130    components.scheme = "https"
1131    components.host = "git.sr.ht"
1132    let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1133    let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1134    let encodedRelativePath = relativePath
1135        .split(separator: "/", omittingEmptySubsequences: false)
1136        .map { segment in
1137            String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1138        }
1139        .joined(separator: "/")
1140    components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1141    return components.string
1142}
1143
1144nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1145    var components: [String] = []
1146
1147    for part in path.split(separator: "/") {
1148        switch part {
1149        case ".":
1150            continue
1151        case "..":
1152            if !components.isEmpty {
1153                components.removeLast()
1154            }
1155        default:
1156            components.append(String(part))
1157        }
1158    }
1159
1160    return components.joined(separator: "/")
1161}
1162
1163nonisolated private func renderTaskListItem(
1164    _ text: String,
1165    inlineRenderer: (String) -> String
1166) -> String {
1167    let trimmed = text.trimmingCharacters(in: .whitespaces)
1168    guard trimmed.count >= 4 else {
1169        return inlineRenderer(text)
1170    }
1171
1172    let prefix = String(trimmed.prefix(4))
1173    let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1174
1175    switch prefix {
1176    case "[ ] ":
1177        return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1178    case "[x] ", "[X] ":
1179        return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1180    default:
1181        return inlineRenderer(text)
1182    }
1183}
1184
1185// MARK: - WKWebView Wrapper
1186
1187/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
1188struct HTMLWebView: View {
1189    let html: String
1190    let colorScheme: ColorScheme
1191    var style: HTMLWebViewStyle = .readme
1192    @Environment(\.openURL) private var openURL
1193    @State private var contentHeight: CGFloat = 1
1194    @State private var loadError: String?
1195    @State private var reloadToken = 0
1196
1197    var body: some View {
1198        Group {
1199            if let loadError {
1200                SRHTErrorStateView(
1201                    title: "Couldn't Render Content",
1202                    message: loadError,
1203                    retryAction: {
1204                        await MainActor.run {
1205                            self.loadError = nil
1206                            reloadToken += 1
1207                        }
1208                    }
1209                )
1210            } else {
1211                HTMLWebViewRepresentable(
1212                    html: html,
1213                    colorScheme: colorScheme,
1214                    style: style,
1215                    openURL: openURL,
1216                    dynamicHeight: $contentHeight,
1217                    loadError: $loadError,
1218                    reloadToken: reloadToken
1219                )
1220                .frame(height: max(contentHeight, 1))
1221            }
1222        }
1223    }
1224}
1225
1226struct HTMLWebViewStyle: Sendable {
1227    let bodyFontSize: Int
1228    let lineHeight: Double
1229    let codeFontSize: Int
1230    let viewport: String
1231
1232    static let readme = HTMLWebViewStyle(
1233        bodyFontSize: 16,
1234        lineHeight: 1.6,
1235        codeFontSize: 13,
1236        viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1237    )
1238
1239    static let commentPreview = HTMLWebViewStyle(
1240        bodyFontSize: 15,
1241        lineHeight: 1.5,
1242        codeFontSize: 12,
1243        viewport: "width=device-width, initial-scale=1, user-scalable=no"
1244    )
1245}
1246
1247private struct HTMLWebViewRepresentable: UIViewRepresentable {
1248    let html: String
1249    let colorScheme: ColorScheme
1250    let style: HTMLWebViewStyle
1251    let openURL: OpenURLAction
1252    @Binding var dynamicHeight: CGFloat
1253    @Binding var loadError: String?
1254    let reloadToken: Int
1255
1256    func makeCoordinator() -> HTMLWebViewCoordinator {
1257        HTMLWebViewCoordinator(parent: self)
1258    }
1259
1260    func makeUIView(context: Context) -> WKWebView {
1261        let config = WKWebViewConfiguration()
1262        config.defaultWebpagePreferences.allowsContentJavaScript = false
1263        config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1264        let webView = WKWebView(frame: .zero, configuration: config)
1265        webView.isOpaque = false
1266        webView.backgroundColor = .clear
1267        webView.clipsToBounds = false
1268        webView.allowsLinkPreview = false
1269        webView.scrollView.isScrollEnabled = false
1270        webView.scrollView.contentInsetAdjustmentBehavior = .never
1271        webView.scrollView.clipsToBounds = false
1272        webView.navigationDelegate = context.coordinator
1273        return webView
1274    }
1275
1276    func updateUIView(_ webView: WKWebView, context: Context) {
1277        let textColor = colorScheme == .dark ? "#fff" : "#000"
1278        let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1279
1280        let wrapped = """
1281        <!DOCTYPE html>
1282        <html>
1283        <head>
1284        <meta name="viewport" content="\(style.viewport)">
1285        <style>
1286            body {
1287                font-family: -apple-system, system-ui, sans-serif;
1288                font-size: \(style.bodyFontSize)px;
1289                line-height: \(style.lineHeight);
1290                padding: 0;
1291                margin: 0;
1292                color: \(textColor);
1293                background: transparent;
1294                word-wrap: break-word;
1295                overflow-wrap: break-word;
1296                max-width: 100%;
1297            }
1298            * { box-sizing: border-box; }
1299            h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1300            p:first-child { margin-top: 0; }
1301            p:last-child { margin-bottom: 0; }
1302            pre, code {
1303                font-family: ui-monospace, Menlo, monospace;
1304                font-size: \(style.codeFontSize)px;
1305                background: rgba(128, 128, 128, 0.15);
1306                padding: 2px 4px;
1307                border-radius: 3px;
1308            }
1309            pre code { padding: 0; background: none; }
1310            pre {
1311                padding: 8px;
1312                overflow-x: auto;
1313                white-space: pre;
1314                word-break: normal;
1315                overflow-wrap: normal;
1316            }
1317            img { max-width: 100%; height: auto; }
1318            input[type="checkbox"] {
1319                margin-right: 0.45rem;
1320                vertical-align: middle;
1321            }
1322            .task-list-item {
1323                display: inline-flex;
1324                align-items: center;
1325                gap: 0.1rem;
1326            }
1327            a { color: \(linkColor); }
1328            table { border-collapse: collapse; width: 100%; }
1329            td, th { border: 1px solid #ccc; padding: 4px 8px; }
1330            blockquote {
1331                border-left: 3px solid rgba(128, 128, 128, 0.5);
1332                margin: 0.5em 0;
1333                padding: 0.25em 0 0.25em 1em;
1334                color: inherit;
1335                opacity: 0.85;
1336            }
1337            hr {
1338                border: none;
1339                border-top: 1px solid rgba(128, 128, 128, 0.35);
1340                margin: 1em 0;
1341            }
1342            table {
1343                border-collapse: collapse;
1344                width: 100%;
1345                margin: 0.75em 0;
1346                font-size: 0.95em;
1347            }
1348            th {
1349                background: rgba(128, 128, 128, 0.15);
1350                font-weight: 600;
1351                text-align: left;
1352            }
1353            td, th {
1354                border: 1px solid rgba(128, 128, 128, 0.3);
1355                padding: 6px 10px;
1356            }
1357            dl.org-properties {
1358                margin: 0.5em 0;
1359                display: grid;
1360                grid-template-columns: max-content 1fr;
1361                gap: 2px 12px;
1362            }
1363            dt {
1364                font-weight: 600;
1365                font-family: ui-monospace, Menlo, monospace;
1366                font-size: 0.9em;
1367            }
1368            dd { margin: 0; }
1369            .org-metadata { margin-bottom: 1em; }
1370            .org-title { margin: 0 0 0.25em; }
1371            .org-author, .org-date {
1372                margin: 0;
1373                color: rgba(128, 128, 128, 0.85);
1374                font-size: 0.9em;
1375            }
1376            del { opacity: 0.7; }
1377        </style>
1378        </head>
1379        <body>\(html)</body>
1380        </html>
1381        """
1382
1383        if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1384            let height = CGFloat(cachedHeight)
1385            if abs(dynamicHeight - height) > 0.5 {
1386                DispatchQueue.main.async {
1387                    if abs(self.dynamicHeight - height) > 0.5 {
1388                        self.dynamicHeight = height
1389                    }
1390                }
1391            }
1392        }
1393
1394        guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1395        context.coordinator.lastHTML = wrapped
1396        context.coordinator.lastReloadToken = reloadToken
1397        if loadError != nil {
1398            DispatchQueue.main.async {
1399                self.loadError = nil
1400            }
1401        }
1402        webView.loadHTMLString(wrapped, baseURL: nil)
1403    }
1404}
1405
1406private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1407    static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1408    static let heightCache = NSCache<NSString, NSNumber>()
1409
1410    let parent: HTMLWebViewRepresentable
1411    var lastHTML: String?
1412    var lastReloadToken = 0
1413
1414    init(parent: HTMLWebViewRepresentable) {
1415        self.parent = parent
1416    }
1417
1418    func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1419        DispatchQueue.main.async {
1420            self.parent.loadError = nil
1421        }
1422        updateHeight(for: webView)
1423    }
1424
1425    func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1426        handleLoadFailure(error)
1427    }
1428
1429    func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1430        handleLoadFailure(error)
1431    }
1432
1433    func webView(
1434        _ webView: WKWebView,
1435        decidePolicyFor navigationAction: WKNavigationAction,
1436        decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1437    ) {
1438        guard let requestURL = navigationAction.request.url else {
1439            decisionHandler(.allow)
1440            return
1441        }
1442
1443        if navigationAction.navigationType == .linkActivated {
1444            if isAllowedReadmeNavigationURL(requestURL) {
1445                parent.openURL(requestURL)
1446            }
1447            decisionHandler(.cancel)
1448            return
1449        }
1450
1451        if isAllowedReadmeNavigationURL(requestURL) {
1452            decisionHandler(.allow)
1453        } else {
1454            decisionHandler(.cancel)
1455        }
1456    }
1457
1458    private func handleLoadFailure(_ error: Error) {
1459        let nsError = error as NSError
1460        guard nsError.code != NSURLErrorCancelled else { return }
1461        DispatchQueue.main.async {
1462            self.parent.loadError = "The content could not be displayed right now."
1463        }
1464    }
1465
1466    private func updateHeight(for webView: WKWebView) {
1467        webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1468            guard let self else { return }
1469            guard let heightValue = result as? NSNumber else { return }
1470            let height = CGFloat(heightValue.doubleValue)
1471            guard height > 0 else { return }
1472            let rounded = ceil(height) + 4
1473            DispatchQueue.main.async {
1474                if let html = self.lastHTML {
1475                    Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1476                }
1477                if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1478                    self.parent.dynamicHeight = rounded
1479                }
1480            }
1481        }
1482    }
1483}