krz/hutch

an ios client for sourcehut

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

v2.8.2: 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(columnAlignment(at: index, in: columnAlignments)) + ">" + 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(columnAlignment(at: index, in: columnAlignments)) + ">" + 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 columnAlignment(at index: Int, in alignments: [String?]) -> String? {
1023    guard alignments.indices.contains(index) else { return nil }
1024    return alignments[index]
1025}
1026
1027nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1028    guard let alignment, !alignment.isEmpty else { return "" }
1029    return #" style="text-align: \#(alignment);""#
1030}
1031
1032private enum OrgListType: Equatable {
1033    case unordered
1034    case ordered
1035}
1036
1037nonisolated private func orderedListItem(in line: String) -> String? {
1038    guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1039    return String(match.2)
1040}
1041
1042nonisolated private func renderOrgListItemBody(
1043    _ lines: [String],
1044    imageURLResolver: ((String) -> String?)? = nil
1045) -> String {
1046    guard let firstLine = lines.first else { return "" }
1047
1048    var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1049    var nestedLines: [String] = []
1050
1051    for line in lines.dropFirst() {
1052        let trimmed = line.trimmingCharacters(in: .whitespaces)
1053        if trimmed.isEmpty {
1054            continue
1055        }
1056
1057        if isIndentedListItemLine(line) {
1058            nestedLines.append(outdentOrgListLine(line))
1059        } else {
1060            contentLines.append(trimmed)
1061        }
1062    }
1063
1064    var html = renderTaskListItem(
1065        contentLines.joined(separator: " "),
1066        inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
1067    )
1068    if !nestedLines.isEmpty {
1069        html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver)
1070    }
1071    return html
1072}
1073
1074nonisolated private func renderNestedOrgListHTML(
1075    _ lines: [String],
1076    imageURLResolver: ((String) -> String?)? = nil
1077) -> String {
1078    var html = ""
1079    var listType: OrgListType?
1080    var currentItemLines: [String] = []
1081
1082    func flushNestedItem() {
1083        guard !currentItemLines.isEmpty else { return }
1084        html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n"
1085        currentItemLines = []
1086    }
1087
1088    func closeNestedList() {
1089        flushNestedItem()
1090        switch listType {
1091        case .unordered:
1092            html += "</ul>\n"
1093        case .ordered:
1094            html += "</ol>\n"
1095        case nil:
1096            break
1097        }
1098        listType = nil
1099    }
1100
1101    for line in lines {
1102        let trimmed = line.trimmingCharacters(in: .whitespaces)
1103        if trimmed.hasPrefix("- ") {
1104            if listType != .unordered {
1105                closeNestedList()
1106                html += "<ul>\n"
1107                listType = .unordered
1108            }
1109            flushNestedItem()
1110            currentItemLines = [String(trimmed.dropFirst(2))]
1111            continue
1112        }
1113
1114        if let orderedItem = orderedListItem(in: trimmed) {
1115            if listType != .ordered {
1116                closeNestedList()
1117                html += "<ol>\n"
1118                listType = .ordered
1119            }
1120            flushNestedItem()
1121            currentItemLines = [orderedItem]
1122            continue
1123        }
1124
1125        if listType != nil {
1126            currentItemLines.append(line)
1127        }
1128    }
1129
1130    closeNestedList()
1131    return html
1132}
1133
1134nonisolated private func protectOrgLinks(
1135    in text: String,
1136    protectedFragments: inout [String: String],
1137    imageURLResolver: ((String) -> String?)? = nil
1138) -> String {
1139    var result = text
1140
1141    while let range = result.range(of: "[[") {
1142        guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1143            break
1144        }
1145        let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1146        protectedFragments[token] = renderOrgLink(
1147            destination: parsed.destination,
1148            label: parsed.label,
1149            imageURLResolver: imageURLResolver
1150        )
1151        result.replaceSubrange(parsed.range, with: token)
1152    }
1153
1154    return result
1155}
1156
1157nonisolated private func parseOrgLink(
1158    in text: String,
1159    from start: String.Index
1160) -> (range: Range<String.Index>, destination: String, label: String?)? {
1161    guard text[start...].hasPrefix("[[") else { return nil }
1162
1163    var index = text.index(start, offsetBy: 2)
1164    guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1165        guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1166        return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1167    }
1168
1169    let destination = String(text[index..<destinationEnd])
1170    index = text.index(destinationEnd, offsetBy: 2)
1171    let labelStart = index
1172    var depth = 0
1173
1174    while index < text.endIndex {
1175        if text[index...].hasPrefix("[[") {
1176            depth += 1
1177            index = text.index(index, offsetBy: 2)
1178            continue
1179        }
1180        if text[index...].hasPrefix("]]") {
1181            if depth == 0 {
1182                let end = text.index(index, offsetBy: 2)
1183                return (start..<end, destination, String(text[labelStart..<index]))
1184            }
1185            depth -= 1
1186            index = text.index(index, offsetBy: 2)
1187            continue
1188        }
1189        index = text.index(after: index)
1190    }
1191
1192    return nil
1193}
1194
1195nonisolated private func renderOrgLink(
1196    destination: String,
1197    label: String?,
1198    imageURLResolver: ((String) -> String?)? = nil
1199) -> String {
1200    if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1201        let source = String(label.dropFirst(2).dropLast(2))
1202        if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1203            guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1204                return imageHTML
1205            }
1206            return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1207        }
1208    }
1209
1210    if let imageHTML = makeOrgImageHTML(
1211        source: destination,
1212        alt: label,
1213        imageURLResolver: imageURLResolver
1214    ) {
1215        return imageHTML
1216    }
1217
1218    guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1219        return label ?? destination
1220    }
1221
1222    let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination
1223    return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1224}
1225
1226nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1227    guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1228        return nil
1229    }
1230    return (
1231        keyword: String(match.1).lowercased(),
1232        value: String(match.2).trimmingCharacters(in: .whitespaces)
1233    )
1234}
1235
1236
1237nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1238    matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1239}
1240
1241nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1242    guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1243    let range = NSRange(location: 0, length: (text as NSString).length)
1244    return regex.firstMatch(in: text, range: range) != nil
1245}
1246
1247nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1248    guard range.location != NSNotFound else { return false }
1249    let prefix = text.substring(to: range.location)
1250    guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
1251    guard let lastClose = prefix.lastIndex(of: ">") else { return true }
1252    return lastOpen > lastClose
1253}
1254
1255nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1256    guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1257    guard let first = line.first else { return false }
1258    return first == " " || first == "\t"
1259}
1260
1261nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1262    guard isIndentedContinuationLine(line) else { return false }
1263    let trimmed = line.trimmingCharacters(in: .whitespaces)
1264    return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1265}
1266
1267nonisolated private func outdentOrgListLine(_ line: String) -> String {
1268    var result = line
1269    while result.first == " " || result.first == "\t" {
1270        result.removeFirst()
1271    }
1272    return result
1273}
1274
1275private extension Array {
1276    subscript(safe index: Int) -> Element? {
1277        guard indices.contains(index) else { return nil }
1278        return self[index]
1279    }
1280}
1281
1282
1283nonisolated func decodeHTMLEntities(_ text: String) -> String {
1284    text
1285        .replacingOccurrences(of: "&amp;", with: "&")
1286        .replacingOccurrences(of: "&quot;", with: "\"")
1287        .replacingOccurrences(of: "&#39;", with: "'")
1288        .replacingOccurrences(of: "&lt;", with: "<")
1289        .replacingOccurrences(of: "&gt;", with: ">")
1290}
1291
1292nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
1293    var protectedFragments: [String: String] = [:]
1294    var foundUnsafeMarkup = false
1295    let protected = protectMatches(
1296        in: rawHTML,
1297        pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
1298        protectedFragments: &protectedFragments
1299    ) { match, nsText in
1300        let rawTag = nsText.substring(with: match.range)
1301        guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
1302            foundUnsafeMarkup = true
1303            return ""
1304        }
1305        return sanitizedTag
1306    }
1307
1308    guard !foundUnsafeMarkup else { return nil }
1309
1310    var sanitized = escapeHTML(protected)
1311    sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
1312        let token = nsText.substring(with: match.range)
1313        return protectedFragments[token] ?? ""
1314    }
1315
1316    return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
1317}
1318
1319nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1320    let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1321    guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1322    guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
1323
1324    let selfClosing = trimmed.hasSuffix("/>")
1325    let contentStart = trimmed.index(after: trimmed.startIndex)
1326    let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
1327    let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
1328    let isClosing = inner.hasPrefix("/")
1329    let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
1330    let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
1331    guard let rawName = parts.first else { return nil }
1332    let tagName = rawName.lowercased()
1333    let allowedTags: Set<String> = [
1334        "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1335        "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1336        "sup", "u", "ul"
1337    ]
1338    guard allowedTags.contains(tagName) else { return nil }
1339
1340    if isClosing {
1341        return "</\(tagName)>"
1342    }
1343
1344    let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1345    let attributes = parseHTMLAttributes(attributePortion)
1346    var renderedAttributes: [String] = []
1347
1348    for (name, value) in attributes {
1349        switch (tagName, name.lowercased()) {
1350        case ("a", "href"):
1351            if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1352                renderedAttributes.append(#"href="\#(sanitized)""#)
1353            }
1354        case ("img", "src"):
1355            if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1356                renderedAttributes.append(#"src="\#(sanitized)""#)
1357            }
1358        case ("img", "alt"), (_, "title"), (_, "class"):
1359            renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1360        default:
1361            continue
1362        }
1363    }
1364
1365    let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1366    let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1367    return "<\(tagName)\(attributeText)\(suffix)>"
1368}
1369
1370nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1371    guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1372        return []
1373    }
1374    let nsText = text as NSString
1375    return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1376        let name = nsText.substring(with: match.range(at: 1))
1377        let value = nsText.substring(with: match.range(at: 2))
1378        return (name, value)
1379    }
1380}
1381
1382nonisolated private func protectMatches(
1383    in text: String,
1384    pattern: String,
1385    protectedFragments: inout [String: String],
1386    transform: (NSTextCheckingResult, NSString) -> String
1387) -> String {
1388    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1389    var result = text
1390    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1391
1392    for match in matches.reversed() {
1393        let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1394        let nsText = result as NSString
1395        protectedFragments[token] = transform(match, nsText)
1396        result = nsText.replacingCharacters(in: match.range, with: token)
1397    }
1398
1399    return result
1400}
1401
1402nonisolated private func replaceMatches(
1403    in text: String,
1404    pattern: String,
1405    transform: (NSTextCheckingResult, NSString) -> String
1406) -> String {
1407    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1408    var result = text
1409    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1410
1411    for match in matches.reversed() {
1412        let nsText = result as NSString
1413        let replacement = transform(match, nsText)
1414        result = nsText.replacingCharacters(in: match.range, with: replacement)
1415    }
1416
1417    return result
1418}
1419
1420nonisolated private func makeOrgImageHTML(
1421    source: String,
1422    alt: String?,
1423    imageURLResolver: ((String) -> String?)?
1424) -> String? {
1425    guard isRenderableImageSource(source) else { return nil }
1426    let resolvedSource = imageURLResolver?(source) ?? source
1427    guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1428    let altText = escapeHTMLAttribute(alt ?? "")
1429    return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1430}
1431
1432nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1433    let lowercased = source.lowercased()
1434    return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1435        .contains(where: { lowercased.hasSuffix($0) })
1436}
1437
1438nonisolated func resolveRepositoryAssetURL(
1439    _ source: String,
1440    owner: String,
1441    repositoryName: String,
1442    readmePath: String?
1443) -> String? {
1444    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1445    guard !trimmedSource.isEmpty else { return nil }
1446
1447    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
1448        return trimmedSource
1449    }
1450
1451    let relativePath: String
1452    if trimmedSource.hasPrefix("/") {
1453        relativePath = String(trimmedSource.dropFirst())
1454    } else {
1455        let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
1456        relativePath = normalizeRepositoryPath(
1457            (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
1458        )
1459    }
1460
1461    guard !relativePath.isEmpty else { return nil }
1462    var components = URLComponents()
1463    components.scheme = "https"
1464    components.host = "git.sr.ht"
1465    let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1466    let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1467    let encodedRelativePath = relativePath
1468        .split(separator: "/", omittingEmptySubsequences: false)
1469        .map { segment in
1470            String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1471        }
1472        .joined(separator: "/")
1473    components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1474    return components.string
1475}
1476
1477nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1478    var components: [String] = []
1479
1480    for part in path.split(separator: "/") {
1481        switch part {
1482        case ".":
1483            continue
1484        case "..":
1485            if !components.isEmpty {
1486                components.removeLast()
1487            }
1488        default:
1489            components.append(String(part))
1490        }
1491    }
1492
1493    return components.joined(separator: "/")
1494}
1495
1496nonisolated private func renderTaskListItem(
1497    _ text: String,
1498    inlineRenderer: (String) -> String
1499) -> String {
1500    let trimmed = text.trimmingCharacters(in: .whitespaces)
1501    guard trimmed.count >= 4 else {
1502        return inlineRenderer(text)
1503    }
1504
1505    let prefix = String(trimmed.prefix(4))
1506    let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1507
1508    switch prefix {
1509    case "[ ] ":
1510        return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1511    case "[x] ", "[X] ":
1512        return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1513    default:
1514        return inlineRenderer(text)
1515    }
1516}
1517
1518// MARK: - WKWebView Wrapper
1519
1520/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
1521struct HTMLWebView: View {
1522    let html: String
1523    let colorScheme: ColorScheme
1524    var style: HTMLWebViewStyle = .readme
1525    @Environment(\.openURL) private var openURL
1526    @State private var contentHeight: CGFloat = 1
1527    @State private var loadError: String?
1528    @State private var reloadToken = 0
1529
1530    var body: some View {
1531        Group {
1532            if let loadError {
1533                SRHTErrorStateView(
1534                    title: "Couldn't Render Content",
1535                    message: loadError,
1536                    retryAction: {
1537                        await MainActor.run {
1538                            self.loadError = nil
1539                            reloadToken += 1
1540                        }
1541                    }
1542                )
1543            } else {
1544                HTMLWebViewRepresentable(
1545                    html: html,
1546                    colorScheme: colorScheme,
1547                    style: style,
1548                    openURL: openURL,
1549                    dynamicHeight: $contentHeight,
1550                    loadError: $loadError,
1551                    reloadToken: reloadToken
1552                )
1553                .frame(height: max(contentHeight, 1))
1554            }
1555        }
1556    }
1557}
1558
1559struct HTMLWebViewStyle: Sendable {
1560    let bodyFontSize: Int
1561    let lineHeight: Double
1562    let codeFontSize: Int
1563    let viewport: String
1564
1565    static let readme = HTMLWebViewStyle(
1566        bodyFontSize: 16,
1567        lineHeight: 1.6,
1568        codeFontSize: 13,
1569        viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1570    )
1571
1572    static let commentPreview = HTMLWebViewStyle(
1573        bodyFontSize: 15,
1574        lineHeight: 1.5,
1575        codeFontSize: 12,
1576        viewport: "width=device-width, initial-scale=1, user-scalable=no"
1577    )
1578}
1579
1580private struct HTMLWebViewRepresentable: UIViewRepresentable {
1581    let html: String
1582    let colorScheme: ColorScheme
1583    let style: HTMLWebViewStyle
1584    let openURL: OpenURLAction
1585    @Binding var dynamicHeight: CGFloat
1586    @Binding var loadError: String?
1587    let reloadToken: Int
1588
1589    func makeCoordinator() -> HTMLWebViewCoordinator {
1590        HTMLWebViewCoordinator(parent: self)
1591    }
1592
1593    func makeUIView(context: Context) -> WKWebView {
1594        let config = WKWebViewConfiguration()
1595        config.defaultWebpagePreferences.allowsContentJavaScript = false
1596        config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1597        let webView = WKWebView(frame: .zero, configuration: config)
1598        webView.isOpaque = false
1599        webView.backgroundColor = .clear
1600        webView.clipsToBounds = false
1601        webView.allowsLinkPreview = false
1602        webView.scrollView.isScrollEnabled = false
1603        webView.scrollView.contentInsetAdjustmentBehavior = .never
1604        webView.scrollView.clipsToBounds = false
1605        webView.navigationDelegate = context.coordinator
1606        return webView
1607    }
1608
1609    func updateUIView(_ webView: WKWebView, context: Context) {
1610        let textColor = colorScheme == .dark ? "#fff" : "#000"
1611        let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1612
1613        let wrapped = """
1614        <!DOCTYPE html>
1615        <html>
1616        <head>
1617        <meta name="viewport" content="\(style.viewport)">
1618        <style>
1619            body {
1620                font-family: -apple-system, system-ui, sans-serif;
1621                font-size: \(style.bodyFontSize)px;
1622                line-height: \(style.lineHeight);
1623                padding: 0;
1624                margin: 0;
1625                color: \(textColor);
1626                background: transparent;
1627                word-wrap: break-word;
1628                overflow-wrap: break-word;
1629                max-width: 100%;
1630            }
1631            * { box-sizing: border-box; }
1632            h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1633            p:first-child { margin-top: 0; }
1634            p:last-child { margin-bottom: 0; }
1635            pre, code {
1636                font-family: ui-monospace, Menlo, monospace;
1637                font-size: \(style.codeFontSize)px;
1638                background: rgba(128, 128, 128, 0.15);
1639                padding: 2px 4px;
1640                border-radius: 3px;
1641            }
1642            pre code { padding: 0; background: none; }
1643            pre {
1644                padding: 8px;
1645                overflow-x: auto;
1646                white-space: pre;
1647                word-break: normal;
1648                overflow-wrap: normal;
1649            }
1650            img { max-width: 100%; height: auto; }
1651            input[type="checkbox"] {
1652                margin-right: 0.45rem;
1653                vertical-align: middle;
1654            }
1655            .task-list-item {
1656                display: inline-flex;
1657                align-items: center;
1658                gap: 0.1rem;
1659            }
1660            a { color: \(linkColor); }
1661            table { border-collapse: collapse; width: 100%; }
1662            td, th { border: 1px solid #ccc; padding: 4px 8px; }
1663            blockquote {
1664                border-left: 3px solid rgba(128, 128, 128, 0.5);
1665                margin: 0.5em 0;
1666                padding: 0.25em 0 0.25em 1em;
1667                color: inherit;
1668                opacity: 0.85;
1669            }
1670            .org-verse {
1671                white-space: pre-wrap;
1672            }
1673            hr {
1674                border: none;
1675                border-top: 1px solid rgba(128, 128, 128, 0.35);
1676                margin: 1em 0;
1677            }
1678            table {
1679                border-collapse: collapse;
1680                width: 100%;
1681                margin: 0.75em 0;
1682                font-size: 0.95em;
1683            }
1684            th {
1685                background: rgba(128, 128, 128, 0.15);
1686                font-weight: 600;
1687                text-align: left;
1688            }
1689            td, th {
1690                border: 1px solid rgba(128, 128, 128, 0.3);
1691                padding: 6px 10px;
1692            }
1693            dl.org-properties {
1694                margin: 0.5em 0;
1695                display: grid;
1696                grid-template-columns: max-content 1fr;
1697                gap: 2px 12px;
1698            }
1699            dt {
1700                font-weight: 600;
1701                font-family: ui-monospace, Menlo, monospace;
1702                font-size: 0.9em;
1703            }
1704            dd { margin: 0; }
1705            .org-metadata { margin-bottom: 1em; }
1706            figure.org-block {
1707                margin: 0.75em 0;
1708            }
1709            figure.org-block figcaption {
1710                margin-top: 0.4em;
1711                color: rgba(128, 128, 128, 0.85);
1712                font-size: 0.9em;
1713            }
1714            .org-title { margin: 0 0 0.25em; }
1715            .org-author, .org-date {
1716                margin: 0;
1717                color: rgba(128, 128, 128, 0.85);
1718                font-size: 0.9em;
1719            }
1720            del { opacity: 0.7; }
1721        </style>
1722        </head>
1723        <body>\(html)</body>
1724        </html>
1725        """
1726
1727        if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1728            let height = CGFloat(cachedHeight)
1729            if abs(dynamicHeight - height) > 0.5 {
1730                DispatchQueue.main.async {
1731                    if abs(self.dynamicHeight - height) > 0.5 {
1732                        self.dynamicHeight = height
1733                    }
1734                }
1735            }
1736        }
1737
1738        guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1739        context.coordinator.lastHTML = wrapped
1740        context.coordinator.lastReloadToken = reloadToken
1741        if loadError != nil {
1742            DispatchQueue.main.async {
1743                self.loadError = nil
1744            }
1745        }
1746        webView.loadHTMLString(wrapped, baseURL: nil)
1747    }
1748}
1749
1750private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1751    static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1752    static let heightCache = NSCache<NSString, NSNumber>()
1753
1754    let parent: HTMLWebViewRepresentable
1755    var lastHTML: String?
1756    var lastReloadToken = 0
1757
1758    init(parent: HTMLWebViewRepresentable) {
1759        self.parent = parent
1760    }
1761
1762    func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1763        DispatchQueue.main.async {
1764            self.parent.loadError = nil
1765        }
1766        updateHeight(for: webView)
1767    }
1768
1769    func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1770        handleLoadFailure(error)
1771    }
1772
1773    func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1774        handleLoadFailure(error)
1775    }
1776
1777    func webView(
1778        _ webView: WKWebView,
1779        decidePolicyFor navigationAction: WKNavigationAction,
1780        decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1781    ) {
1782        guard let requestURL = navigationAction.request.url else {
1783            decisionHandler(.allow)
1784            return
1785        }
1786
1787        if navigationAction.navigationType == .linkActivated {
1788            if isAllowedReadmeNavigationURL(requestURL) {
1789                parent.openURL(requestURL)
1790            }
1791            decisionHandler(.cancel)
1792            return
1793        }
1794
1795        if isAllowedReadmeNavigationURL(requestURL) {
1796            decisionHandler(.allow)
1797        } else {
1798            decisionHandler(.cancel)
1799        }
1800    }
1801
1802    private func handleLoadFailure(_ error: Error) {
1803        let nsError = error as NSError
1804        guard nsError.code != NSURLErrorCancelled else { return }
1805        DispatchQueue.main.async {
1806            self.parent.loadError = "The content could not be displayed right now."
1807        }
1808    }
1809
1810    private func updateHeight(for webView: WKWebView) {
1811        webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1812            guard let self else { return }
1813            guard let heightValue = result as? NSNumber else { return }
1814            let height = CGFloat(heightValue.doubleValue)
1815            guard height > 0 else { return }
1816            let rounded = ceil(height) + 4
1817            DispatchQueue.main.async {
1818                if let html = self.lastHTML {
1819                    Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1820                }
1821                if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1822                    self.parent.dynamicHeight = rounded
1823                }
1824            }
1825        }
1826    }
1827}