krz/hutch

an ios client for sourcehut

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

v3.11.0: Hutch/Views/Repositories/ReadmeView.swift · raw

   1import SwiftUI
   2import WebKit
   3
   4struct ReadmeView: View {
   5    @Environment(AppState.self) private var appState
   6    let viewModel: RepositoryDetailViewModel
   7
   8    @Environment(\.colorScheme) private var colorScheme
   9    @State private var isShowingRepositoryDetails = false
  10    @State private var linkedFile: LinkedFileRequest?
  11
  12    var body: some View {
  13        ScrollView {
  14            VStack(alignment: .leading, spacing: 16) {
  15                headerSection
  16                metadataSection
  17                repositoryDetailsSection
  18                latestChangeSection
  19                readmeSection
  20                if appState.isDebugModeEnabled {
  21                    debugSection
  22                }
  23            }
  24            .padding()
  25        }
  26        .sheet(item: $linkedFile) { request in
  27            LinkedFileSheetView(
  28                rid: viewModel.repository.rid,
  29                service: viewModel.repository.service,
  30                client: appState.client,
  31                request: request
  32            )
  33        }
  34        .task {
  35            async let readme: () = viewModel.loadReadme()
  36            async let commits: () = viewModel.loadCommits()
  37            async let refs: () = viewModel.loadReferences()
  38            _ = await (readme, commits, refs)
  39        }
  40        .navigationDestination(for: CommitSummary.self) { commit in
  41            CommitDetailView(
  42                commitSummary: commit,
  43                repository: viewModel.repository
  44            )
  45        }
  46    }
  47
  48    @ViewBuilder
  49    private var headerSection: some View {
  50        VStack(alignment: .leading, spacing: 6) {
  51            Text(viewModel.repository.owner.canonicalName)
  52                .font(.subheadline)
  53                .foregroundStyle(.secondary)
  54            Text(viewModel.repository.name)
  55                .font(.largeTitle.weight(.semibold))
  56            if let description = viewModel.repository.description, !description.isEmpty {
  57                Text(description)
  58                    .font(.body)
  59            }
  60        }
  61    }
  62
  63    @ViewBuilder
  64    private var metadataSection: some View {
  65        VStack(alignment: .leading, spacing: 10) {
  66            if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
  67                SummaryMetadataRow(
  68                    icon: "arrow.triangle.branch",
  69                    title: branchLabel
  70                )
  71            }
  72
  73            if let readmePath = viewModel.readmePath {
  74                SummaryMetadataRow(
  75                    icon: "doc.text",
  76                    title: readmePath
  77                )
  78            }
  79        }
  80    }
  81
  82    private var repositoryDetailsSection: some View {
  83        DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
  84            VStack(alignment: .leading, spacing: 12) {
  85                SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
  86                SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
  87                SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
  88                SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
  89                SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
  90            }
  91            .padding(.top, 8)
  92        } label: {
  93            Text("Repository Details")
  94                .font(.subheadline.weight(.medium))
  95        }
  96    }
  97
  98    @ViewBuilder
  99    private var latestChangeSection: some View {
 100        VStack(alignment: .leading, spacing: 8) {
 101            if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
 102                SRHTLoadingStateView(message: "Loading latest change…")
 103                    .frame(maxWidth: .infinity)
 104            } else if let commit = viewModel.commits.first {
 105                NavigationLink(value: commit) {
 106                    SummaryMetadataRow(
 107                        icon: "arrow.trianglehead.clockwise",
 108                        title: commit.title,
 109                        subtitle: "\(commit.shortId)\(commit.author.name) \(commit.author.time.relativeDescription)"
 110                    )
 111                    .contentShape(Rectangle())
 112                }
 113                .buttonStyle(.plain)
 114            } else if let error = viewModel.error, viewModel.commits.isEmpty {
 115                SRHTErrorStateView(
 116                    title: "Couldn't Load Latest Change",
 117                    message: error,
 118                    retryAction: { await viewModel.loadCommits() }
 119                )
 120            } else {
 121                ContentUnavailableView(
 122                    "No Recent Commits",
 123                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
 124                    description: Text("This repository does not have any commit history yet.")
 125                )
 126            }
 127        }
 128    }
 129
 130    @ViewBuilder
 131    private var readmeSection: some View {
 132        if viewModel.isLoadingReadme {
 133            SRHTLoadingStateView(message: "Loading README…")
 134        } else if let content = viewModel.readmeContent {
 135            RenderedMarkupContentView(
 136                content: sharedReadmeContent(from: content),
 137                readmePath: viewModel.readmePath,
 138                colorScheme: colorScheme,
 139                ownerCanonicalName: viewModel.repository.owner.canonicalName,
 140                repositoryName: viewModel.repository.name,
 141                onInterceptURL: { url in
 142                    guard let request = parseLinkedFileRequest(url) else { return false }
 143                    linkedFile = request
 144                    return true
 145                }
 146            )
 147        } else if let error = viewModel.error, !viewModel.readmeLoaded {
 148            SRHTErrorStateView(
 149                title: "Couldn't Load README",
 150                message: error,
 151                retryAction: { await viewModel.loadReadme() }
 152            )
 153        } else {
 154            ContentUnavailableView(
 155                "No README",
 156                systemImage: "doc.text",
 157                description: Text("This repository does not have a README file.")
 158            )
 159        }
 160    }
 161
 162    /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest`
 163    /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`.
 164    /// Returns `nil` for any other URL (external links, fragment links, etc.).
 165    private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? {
 166        let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht"
 167        guard let host = url.host, host == expectedHost else { return nil }
 168
 169        // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file
 170        //  ["/", "~owner", "repo", "blob", "HEAD", "file"]
 171        let parts = url.pathComponents
 172        guard parts.count >= 6,
 173              parts[1] == viewModel.repository.owner.canonicalName,
 174              parts[2] == viewModel.repository.name,
 175              parts[3] == "blob" else { return nil }
 176
 177        let revspec = parts[4]
 178        let path = parts[5...].joined(separator: "/")
 179        guard !path.isEmpty else { return nil }
 180
 181        let fileName = parts.last ?? path
 182        return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName)
 183    }
 184
 185    private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
 186        switch content {
 187        case .html(let html):
 188            .html(html)
 189        case .markdown(let text):
 190            .markdown(text)
 191        case .org(let text):
 192            .org(text)
 193        case .plainText(let text):
 194            .plainText(text)
 195        }
 196    }
 197
 198    private var debugSection: some View {
 199        DebugTextBlock(
 200            title: "Debug",
 201            content: """
 202            repositoryId: \(viewModel.repository.id)
 203            rid: \(viewModel.repository.rid)
 204            service: \(viewModel.repository.service.rawValue)
 205            defaultBranch: \(viewModel.repository.defaultBranchName ?? "none")
 206            webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable")
 207            httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable")
 208            sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository))
 209            readmePath: \(viewModel.readmePath ?? "none")
 210            commitsLoaded: \(viewModel.commits.count)
 211            branchesLoaded: \(viewModel.branches.count)
 212            tagsLoaded: \(viewModel.tags.count)
 213            """
 214        )
 215    }
 216}
 217
 218enum RenderedMarkupContent: Sendable {
 219    case html(String)
 220    case markdown(String)
 221    case org(String)
 222    case plainText(String)
 223}
 224
 225struct RenderedMarkupContentView: View {
 226    let content: RenderedMarkupContent
 227    let readmePath: String?
 228    let colorScheme: ColorScheme
 229    let ownerCanonicalName: String
 230    let repositoryName: String
 231    var repositoryHost = "git.sr.ht"
 232    var onInterceptURL: ((URL) -> Bool)? = nil
 233
 234    @State private var renderedHTML: String?
 235
 236    private var cacheKey: String {
 237        // Highlighted code colors are baked into the HTML, so the theme is part
 238        // of the cache identity  light and dark must not share an entry.
 239        let theme = colorScheme == .dark ? "dark" : "light"
 240        switch content {
 241        case .html(let html):
 242            return "html:\(readmePath ?? "custom"):\(html)"
 243        case .markdown(let text):
 244            return "markdown:\(theme):\(readmePath ?? ""):\(text)"
 245        case .org(let text):
 246            return "org:\(theme):\(readmePath ?? ""):\(text)"
 247        case .plainText(let text):
 248            return "plain:\(readmePath ?? ""):\(text)"
 249        }
 250    }
 251
 252    var body: some View {
 253        Group {
 254            switch content {
 255            case .html(let html):
 256                HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
 257            case .markdown, .org:
 258                if let renderedHTML {
 259                    HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
 260                } else {
 261                    SRHTLoadingStateView(message: "Preparing README…")
 262                }
 263            case .plainText(let text):
 264                Text(text)
 265                    .font(.system(.body, design: .monospaced))
 266                    .frame(maxWidth: .infinity, alignment: .leading)
 267            }
 268        }
 269        .task(id: cacheKey) {
 270            await prepareHTMLIfNeeded()
 271        }
 272    }
 273
 274    private func prepareHTMLIfNeeded() async {
 275        switch content {
 276        case .html, .plainText:
 277            renderedHTML = nil
 278        case .markdown(let text):
 279            if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
 280                renderedHTML = cached
 281                return
 282            }
 283            let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
 284            let html = await Task.detached(priority: .userInitiated) {
 285                markdownToHTML(
 286                    text,
 287                    codeTheme: theme,
 288                    imageURLResolver: { source in
 289                        resolveRepositoryAssetURL(
 290                            source,
 291                            owner: ownerCanonicalName,
 292                            repositoryName: repositoryName,
 293                            readmePath: readmePath
 294                        )?
 295                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 296                    },
 297                    linkURLResolver: { source in
 298                        resolveRepositoryLinkURL(
 299                            source,
 300                            owner: ownerCanonicalName,
 301                            repositoryName: repositoryName,
 302                            readmePath: readmePath
 303                        )?
 304                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 305                    }
 306                )
 307            }.value
 308            RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
 309            guard !Task.isCancelled else { return }
 310            renderedHTML = html
 311        case .org(let text):
 312            if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
 313                renderedHTML = cached
 314                return
 315            }
 316            let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
 317            let html = await Task.detached(priority: .userInitiated) {
 318                orgToHTML(
 319                    text,
 320                    codeTheme: theme,
 321                    imageURLResolver: { source in
 322                        resolveRepositoryAssetURL(
 323                            source,
 324                            owner: ownerCanonicalName,
 325                            repositoryName: repositoryName,
 326                            readmePath: readmePath
 327                        )?
 328                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 329                    },
 330                    linkURLResolver: { source in
 331                        resolveRepositoryLinkURL(
 332                            source,
 333                            owner: ownerCanonicalName,
 334                            repositoryName: repositoryName,
 335                            readmePath: readmePath
 336                        )?
 337                        .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
 338                    }
 339                )
 340            }.value
 341            RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
 342            guard !Task.isCancelled else { return }
 343            renderedHTML = html
 344        }
 345    }
 346}
 347
 348private final class RenderedReadmeHTMLCache: @unchecked Sendable {
 349    static let shared = RenderedReadmeHTMLCache()
 350
 351    private let storage = NSCache<NSString, NSString>()
 352
 353    func html(forKey key: String) -> String? {
 354        storage.object(forKey: key as NSString) as String?
 355    }
 356
 357    func setHTML(_ html: String, forKey key: String) {
 358        storage.setObject(html as NSString, forKey: key as NSString)
 359    }
 360
 361    func removeAll() {
 362        storage.removeAllObjects()
 363    }
 364}
 365
 366@MainActor
 367func clearWebContentRenderCaches() {
 368    RenderedReadmeHTMLCache.shared.removeAll()
 369    HTMLWebViewCoordinator.heightCache.removeAllObjects()
 370}
 371
 372// MARK: - Markdown to HTML
 373
 374nonisolated func processInline(
 375    _ text: String,
 376    imageURLResolver: ((String) -> String?)? = nil,
 377    linkURLResolver: ((String) -> String?)? = nil
 378) -> String {
 379
 380    var protectedFragments: [String: String] = [:]
 381
 382    // Code spans render their contents literally, so they have to be taken out of
 383    // the text before any later pass can treat those contents as markup  the tag
 384    // pass below would otherwise promote an allowlisted `<b>` into a live tag.
 385    var result = protectMatches(
 386        in: text,
 387        pattern: #"`([^`]+)`"#,
 388        protectedFragments: &protectedFragments
 389    ) { match, nsText in
 390        let code = nsText.substring(with: match.range(at: 1))
 391        return "<code>\(escapeHTML(code))</code>"
 392    }
 393
 394    result = protectMatches(
 395        in: result,
 396        pattern: #"</?[A-Za-z][^>]*?>"#,
 397        protectedFragments: &protectedFragments
 398    ) { match, nsText in
 399        let rawTag = nsText.substring(with: match.range)
 400        return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
 401    }
 402
 403    result = escapeHTML(result)
 404
 405    // Images: ![alt](url)
 406    result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
 407        let alt = nsText.substring(with: match.range(at: 1))
 408        let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
 409        let resolvedSource = imageURLResolver?(source) ?? source
 410        guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
 411            return escapeHTML(alt)
 412        }
 413        return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
 414    }
 415    // Links: [text](url)
 416    result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
 417        let label = nsText.substring(with: match.range(at: 1))
 418        let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
 419        let resolvedURL = linkURLResolver?(rawURL) ?? rawURL
 420        guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else {
 421            return label
 422        }
 423        return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
 424    }
 425    // Plain email autolinks
 426    result = replaceMatches(
 427        in: result,
 428        pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
 429    ) { match, nsText in
 430        guard !isInsideHTMLTag(nsText, range: match.range) else {
 431            return nsText.substring(with: match.range)
 432        }
 433        let email = nsText.substring(with: match.range(at: 1))
 434        let href = escapeHTMLAttribute("mailto:\(email)")
 435        return #"<a href="\#(href)">\#(email)</a>"#
 436    }
 437    // Strikethrough: ~~text~~
 438    result = result.replacingOccurrences(
 439        of: #"~~(.+?)~~"#,
 440        with: "<del>$1</del>",
 441        options: .regularExpression
 442    )
 443    // Bold: **text**
 444    result = result.replacingOccurrences(
 445        of: #"\*\*(.+?)\*\*"#,
 446        with: "<strong>$1</strong>",
 447        options: .regularExpression
 448    )
 449    // Italic: *text*
 450    result = result.replacingOccurrences(
 451        of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
 452        with: "<em>$1</em>",
 453        options: .regularExpression
 454    )
 455    // Italic: _text_
 456    result = result.replacingOccurrences(
 457        of: #"(?<!\w)_(.+?)_(?!\w)"#,
 458        with: "<em>$1</em>",
 459        options: .regularExpression
 460    )
 461    for (token, fragment) in protectedFragments {
 462        result = result.replacingOccurrences(of: token, with: fragment)
 463    }
 464
 465    return result
 466}
 467
 468// MARK: - Org-mode to HTML
 469
 470nonisolated func orgToHTML(
 471    _ text: String,
 472    codeTheme: SyntaxHighlightTheme = .light,
 473    imageURLResolver: ((String) -> String?)? = nil,
 474    linkURLResolver: ((String) -> String?)? = nil
 475) -> String {
 476    let highlighter = SyntaxHighlighter(theme: codeTheme)
 477    let normalizedText = text
 478        .replacingOccurrences(of: "\r\n", with: "\n")
 479        .replacingOccurrences(of: "\r", with: "\n")
 480    let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
 481    var title: String?
 482    var author: String?
 483    var date: String?
 484    let lines = rawLines.filter { line in
 485        let trimmed = line.trimmingCharacters(in: .whitespaces)
 486        guard let directive = orgKeywordDirective(in: trimmed) else {
 487            return true
 488        }
 489        switch directive.keyword {
 490        case "title":
 491            title = directive.value
 492            return false
 493        case "author":
 494            author = directive.value
 495            return false
 496        case "date":
 497            date = directive.value
 498            return false
 499        default:
 500            return true
 501        }
 502    }
 503    var html = ""
 504    var listType: OrgListType?
 505    var inQuoteBlock = false
 506    var inPropertyDrawer = false
 507    var srcLanguage: String?
 508    var srcLines: [String] = []
 509    var inExampleBlock = false
 510    var inCenterBlock = false
 511    var inVerseBlock = false
 512    var currentListItemLines: [String] = []
 513    var paragraph: [String] = []
 514    var tableRows: [[String]] = []
 515    var propertyRows: [(String, String)] = []
 516    var verseLines: [String] = []
 517    var pendingBlockName: String?
 518    var pendingBlockCaption: String?
 519    var activeBlockCaption: String?
 520    var isWrappingBlockFigure = false
 521
 522    func beginPendingBlockWrapperIfNeeded() {
 523        guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
 524        let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
 525        html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
 526        activeBlockCaption = pendingBlockCaption
 527        isWrappingBlockFigure = true
 528        pendingBlockName = nil
 529        pendingBlockCaption = nil
 530    }
 531
 532    func closePendingBlockWrapper() {
 533        guard isWrappingBlockFigure else { return }
 534        if let activeBlockCaption {
 535            html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
 536        }
 537        html += "</figure>\n"
 538        activeBlockCaption = nil
 539        isWrappingBlockFigure = false
 540    }
 541
 542    func flushParagraph() {
 543        if !paragraph.isEmpty {
 544            let normalizedParagraph = paragraph
 545                .map { $0.trimmingCharacters(in: .whitespaces) }
 546                .joined(separator: " ")
 547            html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
 548            paragraph = []
 549        }
 550    }
 551
 552    func flushListItem() {
 553        guard !currentListItemLines.isEmpty else { return }
 554        html += "<li>" + renderOrgListItemBody(
 555            currentListItemLines,
 556            imageURLResolver: imageURLResolver,
 557            linkURLResolver: linkURLResolver
 558        ) + "</li>\n"
 559        currentListItemLines = []
 560    }
 561
 562    func closeList() {
 563        flushListItem()
 564        switch listType {
 565        case .unordered:
 566            html += "</ul>\n"
 567        case .ordered:
 568            html += "</ol>\n"
 569        case nil:
 570            break
 571        }
 572        listType = nil
 573    }
 574
 575    func flushTable() {
 576        guard !tableRows.isEmpty else { return }
 577        beginPendingBlockWrapperIfNeeded()
 578        html += renderHTMLTable(
 579            rows: tableRows,
 580            inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
 581        )
 582        closePendingBlockWrapper()
 583        tableRows = []
 584    }
 585
 586    func flushPropertyDrawer() {
 587        guard !propertyRows.isEmpty else { return }
 588        html += "<dl class=\"org-properties\">\n"
 589        for (key, value) in propertyRows {
 590            html += "<dt>" + escapeHTML(key) + "</dt>"
 591            html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
 592        }
 593        html += "</dl>\n"
 594        propertyRows = []
 595    }
 596
 597    func closeQuoteBlock() {
 598        if inQuoteBlock {
 599            flushParagraph()
 600            html += "</blockquote>\n"
 601            inQuoteBlock = false
 602        }
 603    }
 604
 605    func closeSourceBlock() {
 606        if let language = srcLanguage {
 607            let code = srcLines.joined(separator: "\n")
 608            if !code.isEmpty {
 609                html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n"
 610            }
 611            html += "</code></pre>\n"
 612            srcLanguage = nil
 613            srcLines = []
 614            closePendingBlockWrapper()
 615        }
 616    }
 617
 618    func closeExampleBlock() {
 619        if inExampleBlock {
 620            html += "</code></pre>\n"
 621            inExampleBlock = false
 622            closePendingBlockWrapper()
 623        }
 624    }
 625
 626    func closeCenterBlock() {
 627        if inCenterBlock {
 628            flushParagraph()
 629            html += "</div>\n"
 630            inCenterBlock = false
 631            closePendingBlockWrapper()
 632        }
 633    }
 634
 635    func closeVerseBlock() {
 636        if inVerseBlock {
 637            let content = verseLines
 638                .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
 639                .joined(separator: "\n")
 640            html += #"<blockquote class="org-verse">"# + "\n"
 641            html += content + "\n"
 642            html += "</blockquote>\n"
 643            verseLines = []
 644            inVerseBlock = false
 645            closePendingBlockWrapper()
 646        }
 647    }
 648
 649    func flushBlockState() {
 650        flushParagraph()
 651        closeList()
 652        flushTable()
 653        flushPropertyDrawer()
 654    }
 655
 656    if title != nil || author != nil || date != nil {
 657        html += "<div class=\"org-metadata\">\n"
 658        if let title {
 659            html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
 660        }
 661        if let author {
 662            html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
 663        }
 664        if let date {
 665            html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
 666        }
 667        html += "</div>\n"
 668    }
 669
 670    for line in lines {
 671        let trimmed = line.trimmingCharacters(in: .whitespaces)
 672
 673        if srcLanguage != nil {
 674            if trimmed.lowercased() == "#+end_src" {
 675                closeSourceBlock()
 676            } else {
 677                srcLines.append(line)
 678            }
 679            continue
 680        }
 681
 682        if inExampleBlock {
 683            if trimmed.lowercased() == "#+end_example" {
 684                closeExampleBlock()
 685            } else {
 686                html += escapeHTML(line) + "\n"
 687            }
 688            continue
 689        }
 690
 691        if inVerseBlock {
 692            if trimmed.lowercased() == "#+end_verse" {
 693                closeVerseBlock()
 694            } else {
 695                verseLines.append(line)
 696            }
 697            continue
 698        }
 699
 700        if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
 701            closeQuoteBlock()
 702            continue
 703        }
 704
 705        if inCenterBlock {
 706            if trimmed.lowercased() == "#+end_center" {
 707                closeCenterBlock()
 708            } else if trimmed.isEmpty {
 709                flushParagraph()
 710            } else {
 711                paragraph.append(line)
 712            }
 713            continue
 714        }
 715
 716        if trimmed == "#" || trimmed.hasPrefix("# ") {
 717            continue
 718        }
 719
 720        if let directive = orgKeywordDirective(in: trimmed) {
 721            switch directive.keyword {
 722            case "caption":
 723                pendingBlockCaption = directive.value
 724                continue
 725            case "name":
 726                pendingBlockName = directive.value
 727                continue
 728            case "options", "property":
 729                continue
 730            default:
 731                break
 732            }
 733        }
 734
 735        if trimmed.lowercased().hasPrefix("#+begin_src") {
 736            closeQuoteBlock()
 737            flushBlockState()
 738            beginPendingBlockWrapperIfNeeded()
 739            let language = trimmed
 740                .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
 741                .dropFirst()
 742                .first
 743                .map(String.init)?
 744                .trimmingCharacters(in: .whitespacesAndNewlines)
 745            let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
 746            html += "<pre><code\(classAttribute)>"
 747            srcLanguage = language ?? ""
 748            srcLines = []
 749            continue
 750        }
 751
 752        if trimmed.lowercased() == "#+begin_example" {
 753            closeQuoteBlock()
 754            flushBlockState()
 755            beginPendingBlockWrapperIfNeeded()
 756            html += "<pre><code>"
 757            inExampleBlock = true
 758            continue
 759        }
 760
 761        if trimmed.lowercased() == "#+begin_quote" {
 762            flushBlockState()
 763            beginPendingBlockWrapperIfNeeded()
 764            html += "<blockquote>\n"
 765            inQuoteBlock = true
 766            continue
 767        }
 768
 769        if trimmed.lowercased() == "#+begin_center" {
 770            closeQuoteBlock()
 771            flushBlockState()
 772            beginPendingBlockWrapperIfNeeded()
 773            html += "<div style=\"text-align:center\">\n"
 774            inCenterBlock = true
 775            continue
 776        }
 777
 778        if trimmed.lowercased() == "#+begin_verse" {
 779            closeQuoteBlock()
 780            flushBlockState()
 781            beginPendingBlockWrapperIfNeeded()
 782            verseLines = []
 783            inVerseBlock = true
 784            continue
 785        }
 786
 787        if trimmed == ":PROPERTIES:" {
 788            closeQuoteBlock()
 789            flushBlockState()
 790            inPropertyDrawer = true
 791            continue
 792        }
 793
 794        if trimmed == ":END:", inPropertyDrawer {
 795            flushPropertyDrawer()
 796            inPropertyDrawer = false
 797            continue
 798        }
 799
 800        if inPropertyDrawer,
 801           trimmed.hasPrefix(":"),
 802           let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
 803            let keyStart = trimmed.index(after: trimmed.startIndex)
 804            let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
 805            let valueStart = trimmed.index(after: secondColonIndex)
 806            let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
 807            if !key.isEmpty {
 808                propertyRows.append((key, value))
 809                continue
 810            }
 811        }
 812
 813        if isTableLine(trimmed) {
 814            closeQuoteBlock()
 815            flushParagraph()
 816            closeList()
 817            tableRows.append(parseTableRow(trimmed))
 818            continue
 819        } else {
 820            flushTable()
 821        }
 822
 823        if isOrgHorizontalRule(trimmed) {
 824            closeQuoteBlock()
 825            flushBlockState()
 826            html += "<hr>\n"
 827            continue
 828        }
 829
 830        // Org headings: * heading, ** heading, *** heading
 831        if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
 832            closeQuoteBlock()
 833            flushBlockState()
 834            let level = match.1.count
 835            let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
 836            html += "<h\(level)>" + content + "</h\(level)>\n"
 837            continue
 838        }
 839
 840        if listType != nil && isIndentedContinuationLine(line) {
 841            currentListItemLines.append(line)
 842            continue
 843        }
 844
 845        // List items: - item
 846        if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
 847            flushParagraph()
 848            flushPropertyDrawer()
 849            if listType != .unordered {
 850                closeList()
 851                html += "<ul>\n"
 852                listType = .unordered
 853            }
 854            flushListItem()
 855            currentListItemLines = [String(trimmed.dropFirst(2))]
 856            continue
 857        }
 858
 859        if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
 860            flushParagraph()
 861            flushPropertyDrawer()
 862            if listType != .ordered {
 863                closeList()
 864                html += "<ol>\n"
 865                listType = .ordered
 866            }
 867            flushListItem()
 868            currentListItemLines = [orderedItem]
 869            continue
 870        }
 871
 872        // Blank line
 873        if trimmed.isEmpty {
 874            if inQuoteBlock {
 875                flushParagraph()
 876            } else {
 877                flushBlockState()
 878            }
 879            continue
 880        }
 881
 882        // Regular text
 883        if pendingBlockName != nil || pendingBlockCaption != nil {
 884            pendingBlockName = nil
 885            pendingBlockCaption = nil
 886        }
 887        paragraph.append(line)
 888    }
 889
 890    closeSourceBlock()
 891    closeExampleBlock()
 892    closeCenterBlock()
 893    closeVerseBlock()
 894    closeQuoteBlock()
 895    flushBlockState()
 896
 897    return html
 898}
 899
 900nonisolated private func processOrgInline(
 901    _ text: String,
 902    imageURLResolver: ((String) -> String?)? = nil,
 903    linkURLResolver: ((String) -> String?)? = nil
 904) -> String {
 905    var result = escapeHTML(text)
 906    var protectedFragments: [String: String] = [:]
 907
 908    result = protectMatches(
 909        in: result,
 910        pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
 911        protectedFragments: &protectedFragments
 912    ) { match, nsText in
 913        let destination = nsText.substring(with: match.range(at: 1))
 914        let source = nsText.substring(with: match.range(at: 2))
 915        guard let imageHTML = makeOrgImageHTML(
 916            source: source,
 917            alt: nil,
 918            imageURLResolver: imageURLResolver
 919        ) else {
 920            return source
 921        }
 922        let resolvedDestination = linkURLResolver?(destination) ?? destination
 923        guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
 924            return imageHTML
 925        }
 926        return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
 927    }
 928
 929    result = protectOrgLinks(
 930        in: result,
 931        protectedFragments: &protectedFragments,
 932        imageURLResolver: imageURLResolver,
 933        linkURLResolver: linkURLResolver
 934    )
 935    result = protectMatches(
 936        in: result,
 937        pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
 938        protectedFragments: &protectedFragments
 939    ) { match, nsText in
 940        let tildeRange = match.range(at: 1)
 941        let equalsRange = match.range(at: 2)
 942        let codeText: String
 943        if tildeRange.location != NSNotFound {
 944            codeText = nsText.substring(with: tildeRange)
 945        } else {
 946            codeText = nsText.substring(with: equalsRange)
 947        }
 948        return "<code>\(codeText)</code>"
 949    }
 950    result = protectMatches(
 951        in: result,
 952        pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
 953        protectedFragments: &protectedFragments
 954    ) { match, nsText in
 955        let value = nsText.substring(with: match.range(at: 1))
 956        return "<del>\(value)</del>"
 957    }
 958    result = protectMatches(
 959        in: result,
 960        pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
 961        protectedFragments: &protectedFragments
 962    ) { match, nsText in
 963        let value = nsText.substring(with: match.range(at: 1))
 964        return "<u>\(value)</u>"
 965    }
 966
 967    // Bold: *text*
 968    result = result.replacingOccurrences(
 969        of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
 970        with: "<strong>$1</strong>",
 971        options: .regularExpression
 972    )
 973    // Italic: /text/
 974    result = result.replacingOccurrences(
 975        of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
 976        with: "<em>$1</em>",
 977        options: .regularExpression
 978    )
 979    result = replaceMatches(
 980        in: result,
 981        pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
 982    ) { match, nsText in
 983        guard !isInsideHTMLTag(nsText, range: match.range) else {
 984            return nsText.substring(with: match.range)
 985        }
 986        let email = nsText.substring(with: match.range(at: 1))
 987        let href = escapeHTMLAttribute("mailto:\(email)")
 988        return #"<a href="\#(href)">\#(email)</a>"#
 989    }
 990
 991    for (token, fragment) in protectedFragments {
 992        result = result.replacingOccurrences(of: token, with: fragment)
 993    }
 994
 995    return result
 996}
 997
 998// MARK: - HTML Escaping
 999
1000nonisolated func escapeHTML(_ text: String) -> String {
1001    text.replacingOccurrences(of: "&", with: "&amp;")
1002        .replacingOccurrences(of: "<", with: "&lt;")
1003        .replacingOccurrences(of: ">", with: "&gt;")
1004        .replacingOccurrences(of: "\"", with: "&quot;")
1005}
1006
1007nonisolated func escapeHTMLAttribute(_ text: String) -> String {
1008    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
1009}
1010
1011nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
1012    sanitizeReadmeURLString(
1013        rawURL,
1014        allowedSchemes: ["http", "https", "mailto"],
1015        allowsFragmentOnly: true
1016    )
1017}
1018
1019nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
1020    sanitizeReadmeURLString(
1021        rawURL,
1022        allowedSchemes: ["http", "https"],
1023        allowsFragmentOnly: false
1024    )
1025}
1026
1027nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
1028    guard let scheme = url.scheme?.lowercased() else {
1029        return false
1030    }
1031    if scheme == "about" || scheme == "data" {
1032        return true
1033    }
1034    guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
1035        return false
1036    }
1037    return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
1038}
1039
1040nonisolated private func sanitizeReadmeURLString(
1041    _ rawURL: String,
1042    allowedSchemes: Set<String>,
1043    allowsFragmentOnly: Bool
1044) -> String? {
1045    let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
1046    guard !trimmedURL.isEmpty else { return nil }
1047
1048    if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
1049        return escapeHTMLAttribute(trimmedURL)
1050    }
1051
1052    guard let components = URLComponents(string: trimmedURL),
1053          let scheme = components.scheme?.lowercased(),
1054          allowedSchemes.contains(scheme),
1055          let sanitizedURL = components.url?.absoluteString else {
1056        return nil
1057    }
1058
1059    return escapeHTMLAttribute(sanitizedURL)
1060}
1061
1062nonisolated private func isTableLine(_ line: String) -> Bool {
1063    line.hasPrefix("|") && line.hasSuffix("|")
1064}
1065
1066nonisolated private func parseTableRow(_ line: String) -> [String] {
1067    line
1068        .split(separator: "|", omittingEmptySubsequences: false)
1069        .dropFirst()
1070        .dropLast()
1071        .map { String($0).trimmingCharacters(in: .whitespaces) }
1072}
1073
1074nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
1075    var content = line.trimmingCharacters(in: .whitespaces)
1076    if content.hasPrefix("|") {
1077        content.removeFirst()
1078    }
1079    if content.hasSuffix("|") {
1080        content.removeLast()
1081    }
1082    return content
1083        .split(separator: "+", omittingEmptySubsequences: false)
1084        .map { String($0).trimmingCharacters(in: .whitespaces) }
1085}
1086
1087nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
1088    tableAlignment(for: cell) != nil
1089}
1090
1091nonisolated private func tableAlignment(for cell: String) -> String? {
1092    let trimmed = cell.trimmingCharacters(in: .whitespaces)
1093    guard !trimmed.isEmpty else { return nil }
1094
1095    let core = trimmed.replacingOccurrences(of: ":", with: "")
1096    guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else {
1097        return nil
1098    }
1099
1100    let isLeftAligned = trimmed.hasPrefix(":")
1101    let isRightAligned = trimmed.hasSuffix(":")
1102    switch (isLeftAligned, isRightAligned) {
1103    case (true, true):
1104        return "center"
1105    case (true, false):
1106        return "left"
1107    case (false, true):
1108        return "right"
1109    case (false, false):
1110        return ""
1111    }
1112}
1113
1114nonisolated private func renderHTMLTable(
1115    rows: [[String]],
1116    inlineRenderer: (String) -> String
1117) -> String {
1118    guard !rows.isEmpty else { return "" }
1119    let separatorCells: [String]
1120    if rows.count > 1, rows[1].count == 1 {
1121        separatorCells = parseOrgTableSeparatorRow(rows[1][0])
1122    } else {
1123        separatorCells = rows.count > 1 ? rows[1] : []
1124    }
1125    let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell)
1126    let headerRow = rows.first ?? []
1127    let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
1128    let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : []
1129    var html = "<table>\n"
1130
1131    if hasHeaderSeparator {
1132        html += "<thead><tr>"
1133        for (index, cell) in headerRow.enumerated() {
1134            html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>"
1135        }
1136        html += "</tr></thead>\n"
1137    }
1138
1139    html += "<tbody>\n"
1140    for row in bodyRows {
1141        html += "<tr>"
1142        for (index, cell) in row.enumerated() {
1143            html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>"
1144        }
1145        html += "</tr>\n"
1146    }
1147    html += "</tbody>\n"
1148    html += "</table>\n"
1149    return html
1150}
1151
1152nonisolated private func columnAlignment(at index: Int, in alignments: [String?]) -> String? {
1153    guard alignments.indices.contains(index) else { return nil }
1154    return alignments[index]
1155}
1156
1157nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1158    guard let alignment, !alignment.isEmpty else { return "" }
1159    return #" style="text-align: \#(alignment);""#
1160}
1161
1162private enum OrgListType: Equatable {
1163    case unordered
1164    case ordered
1165}
1166
1167nonisolated private func orderedListItem(in line: String) -> String? {
1168    guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1169    return String(match.2)
1170}
1171
1172nonisolated private func renderOrgListItemBody(
1173    _ lines: [String],
1174    imageURLResolver: ((String) -> String?)? = nil,
1175    linkURLResolver: ((String) -> String?)? = nil
1176) -> String {
1177    guard let firstLine = lines.first else { return "" }
1178
1179    var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1180    var nestedLines: [String] = []
1181
1182    for line in lines.dropFirst() {
1183        let trimmed = line.trimmingCharacters(in: .whitespaces)
1184        if trimmed.isEmpty {
1185            continue
1186        }
1187
1188        if isIndentedListItemLine(line) {
1189            nestedLines.append(outdentOrgListLine(line))
1190        } else {
1191            contentLines.append(trimmed)
1192        }
1193    }
1194
1195    var html = renderTaskListItem(
1196        contentLines.joined(separator: " "),
1197        inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
1198    )
1199    if !nestedLines.isEmpty {
1200        html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1201    }
1202    return html
1203}
1204
1205nonisolated private func renderNestedOrgListHTML(
1206    _ lines: [String],
1207    imageURLResolver: ((String) -> String?)? = nil,
1208    linkURLResolver: ((String) -> String?)? = nil
1209) -> String {
1210    var html = ""
1211    var listType: OrgListType?
1212    var currentItemLines: [String] = []
1213
1214    func flushNestedItem() {
1215        guard !currentItemLines.isEmpty else { return }
1216        html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
1217        currentItemLines = []
1218    }
1219
1220    func closeNestedList() {
1221        flushNestedItem()
1222        switch listType {
1223        case .unordered:
1224            html += "</ul>\n"
1225        case .ordered:
1226            html += "</ol>\n"
1227        case nil:
1228            break
1229        }
1230        listType = nil
1231    }
1232
1233    for line in lines {
1234        let trimmed = line.trimmingCharacters(in: .whitespaces)
1235        if trimmed.hasPrefix("- ") {
1236            if listType != .unordered {
1237                closeNestedList()
1238                html += "<ul>\n"
1239                listType = .unordered
1240            }
1241            flushNestedItem()
1242            currentItemLines = [String(trimmed.dropFirst(2))]
1243            continue
1244        }
1245
1246        if let orderedItem = orderedListItem(in: trimmed) {
1247            if listType != .ordered {
1248                closeNestedList()
1249                html += "<ol>\n"
1250                listType = .ordered
1251            }
1252            flushNestedItem()
1253            currentItemLines = [orderedItem]
1254            continue
1255        }
1256
1257        if listType != nil {
1258            currentItemLines.append(line)
1259        }
1260    }
1261
1262    closeNestedList()
1263    return html
1264}
1265
1266nonisolated private func protectOrgLinks(
1267    in text: String,
1268    protectedFragments: inout [String: String],
1269    imageURLResolver: ((String) -> String?)? = nil,
1270    linkURLResolver: ((String) -> String?)? = nil
1271) -> String {
1272    var result = text
1273
1274    while let range = result.range(of: "[[") {
1275        guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1276            break
1277        }
1278        let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1279        protectedFragments[token] = renderOrgLink(
1280            destination: parsed.destination,
1281            label: parsed.label,
1282            imageURLResolver: imageURLResolver,
1283            linkURLResolver: linkURLResolver
1284        )
1285        result.replaceSubrange(parsed.range, with: token)
1286    }
1287
1288    return result
1289}
1290
1291nonisolated private func parseOrgLink(
1292    in text: String,
1293    from start: String.Index
1294) -> (range: Range<String.Index>, destination: String, label: String?)? {
1295    guard text[start...].hasPrefix("[[") else { return nil }
1296
1297    var index = text.index(start, offsetBy: 2)
1298    guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1299        guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1300        return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1301    }
1302
1303    let destination = String(text[index..<destinationEnd])
1304    index = text.index(destinationEnd, offsetBy: 2)
1305    let labelStart = index
1306    var depth = 0
1307
1308    while index < text.endIndex {
1309        if text[index...].hasPrefix("[[") {
1310            depth += 1
1311            index = text.index(index, offsetBy: 2)
1312            continue
1313        }
1314        if text[index...].hasPrefix("]]") {
1315            if depth == 0 {
1316                let end = text.index(index, offsetBy: 2)
1317                return (start..<end, destination, String(text[labelStart..<index]))
1318            }
1319            depth -= 1
1320            index = text.index(index, offsetBy: 2)
1321            continue
1322        }
1323        index = text.index(after: index)
1324    }
1325
1326    return nil
1327}
1328
1329nonisolated private func renderOrgLink(
1330    destination: String,
1331    label: String?,
1332    imageURLResolver: ((String) -> String?)? = nil,
1333    linkURLResolver: ((String) -> String?)? = nil
1334) -> String {
1335    if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1336        let source = String(label.dropFirst(2).dropLast(2))
1337        if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1338            let resolvedDestination = linkURLResolver?(destination) ?? destination
1339            guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1340                return imageHTML
1341            }
1342            return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1343        }
1344    }
1345
1346    if let imageHTML = makeOrgImageHTML(
1347        source: destination,
1348        alt: label,
1349        imageURLResolver: imageURLResolver
1350    ) {
1351        return imageHTML
1352    }
1353
1354    let resolvedDestination = linkURLResolver?(destination) ?? destination
1355    guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1356        return label ?? destination
1357    }
1358
1359    let renderedLabel = label.map {
1360        processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1361    } ?? destination
1362    return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1363}
1364
1365nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1366    guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1367        return nil
1368    }
1369    return (
1370        keyword: String(match.1).lowercased(),
1371        value: String(match.2).trimmingCharacters(in: .whitespaces)
1372    )
1373}
1374
1375
1376nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1377    matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1378}
1379
1380nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1381    guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1382    let range = NSRange(location: 0, length: (text as NSString).length)
1383    return regex.firstMatch(in: text, range: range) != nil
1384}
1385
1386nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1387    guard range.location != NSNotFound else { return false }
1388    let prefix = text.substring(to: range.location)
1389    guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
1390    guard let lastClose = prefix.lastIndex(of: ">") else { return true }
1391    return lastOpen > lastClose
1392}
1393
1394nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1395    guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1396    guard let first = line.first else { return false }
1397    return first == " " || first == "\t"
1398}
1399
1400nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1401    guard isIndentedContinuationLine(line) else { return false }
1402    let trimmed = line.trimmingCharacters(in: .whitespaces)
1403    return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1404}
1405
1406nonisolated private func outdentOrgListLine(_ line: String) -> String {
1407    var result = line
1408    while result.first == " " || result.first == "\t" {
1409        result.removeFirst()
1410    }
1411    return result
1412}
1413
1414private extension Array {
1415    subscript(safe index: Int) -> Element? {
1416        guard indices.contains(index) else { return nil }
1417        return self[index]
1418    }
1419}
1420
1421
1422nonisolated func decodeHTMLEntities(_ text: String) -> String {
1423    text
1424        .replacingOccurrences(of: "&amp;", with: "&")
1425        .replacingOccurrences(of: "&quot;", with: "\"")
1426        .replacingOccurrences(of: "&#39;", with: "'")
1427        .replacingOccurrences(of: "&lt;", with: "<")
1428        .replacingOccurrences(of: "&gt;", with: ">")
1429}
1430
1431nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
1432    var protectedFragments: [String: String] = [:]
1433    var foundUnsafeMarkup = false
1434    let protected = protectMatches(
1435        in: rawHTML,
1436        pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
1437        protectedFragments: &protectedFragments
1438    ) { match, nsText in
1439        let rawTag = nsText.substring(with: match.range)
1440        guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
1441            foundUnsafeMarkup = true
1442            return ""
1443        }
1444        return sanitizedTag
1445    }
1446
1447    guard !foundUnsafeMarkup else { return nil }
1448
1449    var sanitized = escapeHTML(protected)
1450    sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
1451        let token = nsText.substring(with: match.range)
1452        return protectedFragments[token] ?? ""
1453    }
1454
1455    return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
1456}
1457
1458nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1459    let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1460    guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1461    guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
1462
1463    let selfClosing = trimmed.hasSuffix("/>")
1464    let contentStart = trimmed.index(after: trimmed.startIndex)
1465    let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
1466    let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
1467    let isClosing = inner.hasPrefix("/")
1468    let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
1469    let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
1470    guard let rawName = parts.first else { return nil }
1471    let tagName = rawName.lowercased()
1472    let allowedTags: Set<String> = [
1473        "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1474        "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1475        "sup", "u", "ul"
1476    ]
1477    guard allowedTags.contains(tagName) else { return nil }
1478
1479    if isClosing {
1480        return "</\(tagName)>"
1481    }
1482
1483    let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1484    let attributes = parseHTMLAttributes(attributePortion)
1485    var renderedAttributes: [String] = []
1486
1487    for (name, value) in attributes {
1488        switch (tagName, name.lowercased()) {
1489        case ("a", "href"):
1490            if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1491                renderedAttributes.append(#"href="\#(sanitized)""#)
1492            }
1493        case ("img", "src"):
1494            if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1495                renderedAttributes.append(#"src="\#(sanitized)""#)
1496            }
1497        case ("img", "alt"), (_, "title"), (_, "class"):
1498            renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1499        default:
1500            continue
1501        }
1502    }
1503
1504    let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1505    let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1506    return "<\(tagName)\(attributeText)\(suffix)>"
1507}
1508
1509nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1510    guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1511        return []
1512    }
1513    let nsText = text as NSString
1514    return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1515        let name = nsText.substring(with: match.range(at: 1))
1516        let value = nsText.substring(with: match.range(at: 2))
1517        return (name, value)
1518    }
1519}
1520
1521nonisolated private func protectMatches(
1522    in text: String,
1523    pattern: String,
1524    protectedFragments: inout [String: String],
1525    transform: (NSTextCheckingResult, NSString) -> String
1526) -> String {
1527    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1528    var result = text
1529    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1530
1531    for match in matches.reversed() {
1532        let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1533        let nsText = result as NSString
1534        protectedFragments[token] = transform(match, nsText)
1535        result = nsText.replacingCharacters(in: match.range, with: token)
1536    }
1537
1538    return result
1539}
1540
1541nonisolated private func replaceMatches(
1542    in text: String,
1543    pattern: String,
1544    transform: (NSTextCheckingResult, NSString) -> String
1545) -> String {
1546    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1547    var result = text
1548    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1549
1550    for match in matches.reversed() {
1551        let nsText = result as NSString
1552        let replacement = transform(match, nsText)
1553        result = nsText.replacingCharacters(in: match.range, with: replacement)
1554    }
1555
1556    return result
1557}
1558
1559nonisolated private func makeOrgImageHTML(
1560    source: String,
1561    alt: String?,
1562    imageURLResolver: ((String) -> String?)?
1563) -> String? {
1564    guard isRenderableImageSource(source) else { return nil }
1565    let resolvedSource = imageURLResolver?(source) ?? source
1566    guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1567    let altText = escapeHTMLAttribute(alt ?? "")
1568    return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1569}
1570
1571nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1572    let lowercased = source.lowercased()
1573    return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1574        .contains(where: { lowercased.hasSuffix($0) })
1575}
1576
1577nonisolated func resolveRepositoryLinkURL(
1578    _ source: String,
1579    owner: String,
1580    repositoryName: String,
1581    readmePath: String?
1582) -> String? {
1583    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1584    guard !trimmedSource.isEmpty else { return nil }
1585
1586    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
1587        || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
1588        return trimmedSource
1589    }
1590
1591    return resolveRepositoryAssetURL(
1592        trimmedSource,
1593        owner: owner,
1594        repositoryName: repositoryName,
1595        readmePath: readmePath
1596    )
1597}
1598
1599nonisolated func resolveRepositoryAssetURL(
1600    _ source: String,
1601    owner: String,
1602    repositoryName: String,
1603    readmePath: String?
1604) -> String? {
1605    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1606    guard !trimmedSource.isEmpty else { return nil }
1607
1608    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
1609        return trimmedSource
1610    }
1611
1612    let relativePath: String
1613    if trimmedSource.hasPrefix("/") {
1614        relativePath = String(trimmedSource.dropFirst())
1615    } else {
1616        let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
1617        relativePath = normalizeRepositoryPath(
1618            (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
1619        )
1620    }
1621
1622    guard !relativePath.isEmpty else { return nil }
1623    var components = URLComponents()
1624    components.scheme = "https"
1625    components.host = "git.sr.ht"
1626    let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1627    let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1628    let encodedRelativePath = relativePath
1629        .split(separator: "/", omittingEmptySubsequences: false)
1630        .map { segment in
1631            String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1632        }
1633        .joined(separator: "/")
1634    components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1635    return components.string
1636}
1637
1638nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1639    var components: [String] = []
1640
1641    for part in path.split(separator: "/") {
1642        switch part {
1643        case ".":
1644            continue
1645        case "..":
1646            if !components.isEmpty {
1647                components.removeLast()
1648            }
1649        default:
1650            components.append(String(part))
1651        }
1652    }
1653
1654    return components.joined(separator: "/")
1655}
1656
1657nonisolated private func renderTaskListItem(
1658    _ text: String,
1659    inlineRenderer: (String) -> String
1660) -> String {
1661    let trimmed = text.trimmingCharacters(in: .whitespaces)
1662    guard trimmed.count >= 4 else {
1663        return inlineRenderer(text)
1664    }
1665
1666    let prefix = String(trimmed.prefix(4))
1667    let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1668
1669    switch prefix {
1670    case "[ ] ":
1671        return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1672    case "[x] ", "[X] ":
1673        return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1674    default:
1675        return inlineRenderer(text)
1676    }
1677}
1678
1679// MARK: - WKWebView Wrapper
1680
1681/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
1682struct HTMLWebView: View {
1683    let html: String
1684    let colorScheme: ColorScheme
1685    var style: HTMLWebViewStyle = .readme
1686    var baseURL: URL? = nil
1687    var onInterceptURL: ((URL) -> Bool)? = nil
1688    @Environment(\.openURL) private var openURL
1689    @State private var contentHeight: CGFloat = 1
1690    @State private var loadError: String?
1691    @State private var reloadToken = 0
1692
1693    var body: some View {
1694        Group {
1695            if let loadError {
1696                SRHTErrorStateView(
1697                    title: "Couldn't Render Content",
1698                    message: loadError,
1699                    retryAction: {
1700                        await MainActor.run {
1701                            self.loadError = nil
1702                            reloadToken += 1
1703                        }
1704                    }
1705                )
1706            } else {
1707                HTMLWebViewRepresentable(
1708                    html: html,
1709                    colorScheme: colorScheme,
1710                    style: style,
1711                    baseURL: baseURL,
1712                    onInterceptURL: onInterceptURL,
1713                    openURL: openURL,
1714                    dynamicHeight: $contentHeight,
1715                    loadError: $loadError,
1716                    reloadToken: reloadToken
1717                )
1718                .frame(height: max(contentHeight, 1))
1719            }
1720        }
1721    }
1722}
1723
1724struct HTMLWebViewStyle: Sendable {
1725    let bodyFontSize: Int
1726    let lineHeight: Double
1727    let codeFontSize: Int
1728    let viewport: String
1729
1730    static let readme = HTMLWebViewStyle(
1731        bodyFontSize: 16,
1732        lineHeight: 1.6,
1733        codeFontSize: 13,
1734        viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1735    )
1736
1737    static let commentPreview = HTMLWebViewStyle(
1738        bodyFontSize: 15,
1739        lineHeight: 1.5,
1740        codeFontSize: 12,
1741        viewport: "width=device-width, initial-scale=1, user-scalable=no"
1742    )
1743}
1744
1745private struct HTMLWebViewRepresentable: UIViewRepresentable {
1746    let html: String
1747    let colorScheme: ColorScheme
1748    let style: HTMLWebViewStyle
1749    let baseURL: URL?
1750    let onInterceptURL: ((URL) -> Bool)?
1751    let openURL: OpenURLAction
1752    @Binding var dynamicHeight: CGFloat
1753    @Binding var loadError: String?
1754    let reloadToken: Int
1755
1756    func makeCoordinator() -> HTMLWebViewCoordinator {
1757        HTMLWebViewCoordinator(parent: self)
1758    }
1759
1760    func makeUIView(context: Context) -> WKWebView {
1761        let config = WKWebViewConfiguration()
1762        config.defaultWebpagePreferences.allowsContentJavaScript = false
1763        config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1764        let webView = WKWebView(frame: .zero, configuration: config)
1765        webView.isOpaque = false
1766        webView.backgroundColor = .clear
1767        webView.clipsToBounds = false
1768        webView.allowsLinkPreview = false
1769        webView.scrollView.isScrollEnabled = false
1770        webView.scrollView.contentInsetAdjustmentBehavior = .never
1771        webView.scrollView.clipsToBounds = false
1772        webView.navigationDelegate = context.coordinator
1773        return webView
1774    }
1775
1776    func updateUIView(_ webView: WKWebView, context: Context) {
1777        context.coordinator.parent = self
1778        let textColor = colorScheme == .dark ? "#fff" : "#000"
1779        let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1780
1781        let wrapped = """
1782        <!DOCTYPE html>
1783        <html>
1784        <head>
1785        <meta name="viewport" content="\(style.viewport)">
1786        <style>
1787            body {
1788                font-family: -apple-system, system-ui, sans-serif;
1789                font-size: \(style.bodyFontSize)px;
1790                line-height: \(style.lineHeight);
1791                padding: 0;
1792                margin: 0;
1793                color: \(textColor);
1794                background: transparent;
1795                word-wrap: break-word;
1796                overflow-wrap: break-word;
1797                max-width: 100%;
1798            }
1799            * { box-sizing: border-box; }
1800            h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1801            p:first-child { margin-top: 0; }
1802            p:last-child { margin-bottom: 0; }
1803            pre, code {
1804                font-family: ui-monospace, Menlo, monospace;
1805                font-size: \(style.codeFontSize)px;
1806                background: rgba(128, 128, 128, 0.15);
1807                padding: 2px 4px;
1808                border-radius: 3px;
1809            }
1810            pre code { padding: 0; background: none; }
1811            pre {
1812                padding: 8px;
1813                overflow-x: auto;
1814                white-space: pre;
1815                word-break: normal;
1816                overflow-wrap: normal;
1817            }
1818            img { max-width: 100%; height: auto; }
1819            svg {
1820                max-width: 100%;
1821                height: auto;
1822            }
1823            input[type="checkbox"] {
1824                margin-right: 0.45rem;
1825                vertical-align: middle;
1826            }
1827            .task-list-item {
1828                display: inline;
1829            }
1830            a { color: \(linkColor); }
1831            table { border-collapse: collapse; width: 100%; }
1832            td, th { border: 1px solid #ccc; padding: 4px 8px; }
1833            blockquote {
1834                border-left: 3px solid rgba(128, 128, 128, 0.5);
1835                margin: 0.5em 0;
1836                padding: 0.25em 0 0.25em 1em;
1837                color: inherit;
1838                opacity: 0.85;
1839            }
1840            .org-verse {
1841                white-space: pre-wrap;
1842            }
1843            hr {
1844                border: none;
1845                border-top: 1px solid rgba(128, 128, 128, 0.35);
1846                margin: 1em 0;
1847            }
1848            table {
1849                border-collapse: collapse;
1850                width: 100%;
1851                margin: 0.75em 0;
1852                font-size: 0.95em;
1853            }
1854            th {
1855                background: rgba(128, 128, 128, 0.15);
1856                font-weight: 600;
1857                text-align: left;
1858            }
1859            td, th {
1860                border: 1px solid rgba(128, 128, 128, 0.3);
1861                padding: 6px 10px;
1862            }
1863            dl.org-properties {
1864                margin: 0.5em 0;
1865                display: grid;
1866                grid-template-columns: max-content 1fr;
1867                gap: 2px 12px;
1868            }
1869            dt {
1870                font-weight: 600;
1871                font-family: ui-monospace, Menlo, monospace;
1872                font-size: 0.9em;
1873            }
1874            dd { margin: 0; }
1875            .org-metadata { margin-bottom: 1em; }
1876            figure.org-block {
1877                margin: 0.75em 0;
1878            }
1879            figure.org-block figcaption {
1880                margin-top: 0.4em;
1881                color: rgba(128, 128, 128, 0.85);
1882                font-size: 0.9em;
1883            }
1884            .btn {
1885                display: inline-flex;
1886                align-items: center;
1887                gap: 0.4em;
1888            }
1889            .icon {
1890                display: inline-flex;
1891                align-items: center;
1892                vertical-align: middle;
1893            }
1894            .icon svg {
1895                width: 0.65em;
1896                height: 0.65em;
1897                display: block;
1898                fill: currentColor;
1899            }
1900            .org-title { margin: 0 0 0.25em; }
1901            .org-author, .org-date {
1902                margin: 0;
1903                color: rgba(128, 128, 128, 0.85);
1904                font-size: 0.9em;
1905            }
1906            del { opacity: 0.7; }
1907        </style>
1908        </head>
1909        <body>\(html)</body>
1910        </html>
1911        """
1912
1913        if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1914            let height = CGFloat(cachedHeight)
1915            if abs(dynamicHeight - height) > 0.5 {
1916                DispatchQueue.main.async {
1917                    if abs(self.dynamicHeight - height) > 0.5 {
1918                        self.dynamicHeight = height
1919                    }
1920                }
1921            }
1922        }
1923
1924        guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1925        context.coordinator.lastHTML = wrapped
1926        context.coordinator.lastReloadToken = reloadToken
1927        if loadError != nil {
1928            DispatchQueue.main.async {
1929                self.loadError = nil
1930            }
1931        }
1932        webView.loadHTMLString(wrapped, baseURL: baseURL)
1933    }
1934}
1935
1936private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1937    static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1938    static let heightCache = NSCache<NSString, NSNumber>()
1939
1940    var parent: HTMLWebViewRepresentable
1941    var lastHTML: String?
1942    var lastReloadToken = 0
1943
1944    init(parent: HTMLWebViewRepresentable) {
1945        self.parent = parent
1946    }
1947
1948    func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1949        DispatchQueue.main.async {
1950            self.parent.loadError = nil
1951        }
1952        updateHeight(for: webView)
1953    }
1954
1955    func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1956        handleLoadFailure(error)
1957    }
1958
1959    func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1960        handleLoadFailure(error)
1961    }
1962
1963    func webView(
1964        _: WKWebView,
1965        decidePolicyFor navigationAction: WKNavigationAction,
1966        decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1967    ) {
1968        guard let requestURL = navigationAction.request.url else {
1969            decisionHandler(.allow)
1970            return
1971        }
1972
1973        if navigationAction.navigationType == .linkActivated {
1974            if isSameDocumentFragmentNavigation(requestURL) {
1975                decisionHandler(.allow)
1976                return
1977            }
1978            if let intercept = parent.onInterceptURL, intercept(requestURL) {
1979                decisionHandler(.cancel)
1980                return
1981            }
1982            if isAllowedReadmeNavigationURL(requestURL) {
1983                parent.openURL(requestURL)
1984            }
1985            decisionHandler(.cancel)
1986            return
1987        }
1988
1989        if isAllowedReadmeNavigationURL(requestURL) {
1990            decisionHandler(.allow)
1991        } else {
1992            decisionHandler(.cancel)
1993        }
1994    }
1995
1996    private func handleLoadFailure(_ error: Error) {
1997        let nsError = error as NSError
1998        guard nsError.code != NSURLErrorCancelled else { return }
1999        DispatchQueue.main.async {
2000            self.parent.loadError = "The content could not be displayed right now."
2001        }
2002    }
2003
2004    private func updateHeight(for webView: WKWebView) {
2005        webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
2006            guard let self else { return }
2007            guard let heightValue = result as? NSNumber else { return }
2008            let height = CGFloat(heightValue.doubleValue)
2009            guard height > 0 else { return }
2010            let rounded = ceil(height) + 4
2011            DispatchQueue.main.async {
2012                if let html = self.lastHTML {
2013                    Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
2014                }
2015                if abs(self.parent.dynamicHeight - rounded) > 0.5 {
2016                    self.parent.dynamicHeight = rounded
2017                }
2018            }
2019        }
2020    }
2021
2022    private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
2023        guard url.fragment != nil,
2024              let baseURL = parent.baseURL else {
2025            return false
2026        }
2027
2028        guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
2029              var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
2030            return false
2031        }
2032
2033        destination.fragment = nil
2034        base.fragment = nil
2035        return destination.url == base.url
2036    }
2037}