krz/hutch

an ios client for sourcehut

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

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