krz/hutch

an ios client for sourcehut

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

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

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