krz/hutch

an ios client for sourcehut

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

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