krz/hutch

an ios client for sourcehut

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

v2.5.1: Hutch/Views/Repositories/ReadmeView.swift · raw

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