krz/hutch

an ios client for sourcehut

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

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