krz/hutch

an ios client for sourcehut

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

v2.14.0: 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    var baseURL: URL? = nil
1526    var onInterceptURL: ((URL) -> Bool)? = nil
1527    @Environment(\.openURL) private var openURL
1528    @State private var contentHeight: CGFloat = 1
1529    @State private var loadError: String?
1530    @State private var reloadToken = 0
1531
1532    var body: some View {
1533        Group {
1534            if let loadError {
1535                SRHTErrorStateView(
1536                    title: "Couldn't Render Content",
1537                    message: loadError,
1538                    retryAction: {
1539                        await MainActor.run {
1540                            self.loadError = nil
1541                            reloadToken += 1
1542                        }
1543                    }
1544                )
1545            } else {
1546                HTMLWebViewRepresentable(
1547                    html: html,
1548                    colorScheme: colorScheme,
1549                    style: style,
1550                    baseURL: baseURL,
1551                    onInterceptURL: onInterceptURL,
1552                    openURL: openURL,
1553                    dynamicHeight: $contentHeight,
1554                    loadError: $loadError,
1555                    reloadToken: reloadToken
1556                )
1557                .frame(height: max(contentHeight, 1))
1558            }
1559        }
1560    }
1561}
1562
1563struct HTMLWebViewStyle: Sendable {
1564    let bodyFontSize: Int
1565    let lineHeight: Double
1566    let codeFontSize: Int
1567    let viewport: String
1568
1569    static let readme = HTMLWebViewStyle(
1570        bodyFontSize: 16,
1571        lineHeight: 1.6,
1572        codeFontSize: 13,
1573        viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1574    )
1575
1576    static let commentPreview = HTMLWebViewStyle(
1577        bodyFontSize: 15,
1578        lineHeight: 1.5,
1579        codeFontSize: 12,
1580        viewport: "width=device-width, initial-scale=1, user-scalable=no"
1581    )
1582}
1583
1584private struct HTMLWebViewRepresentable: UIViewRepresentable {
1585    let html: String
1586    let colorScheme: ColorScheme
1587    let style: HTMLWebViewStyle
1588    let baseURL: URL?
1589    let onInterceptURL: ((URL) -> Bool)?
1590    let openURL: OpenURLAction
1591    @Binding var dynamicHeight: CGFloat
1592    @Binding var loadError: String?
1593    let reloadToken: Int
1594
1595    func makeCoordinator() -> HTMLWebViewCoordinator {
1596        HTMLWebViewCoordinator(parent: self)
1597    }
1598
1599    func makeUIView(context: Context) -> WKWebView {
1600        let config = WKWebViewConfiguration()
1601        config.defaultWebpagePreferences.allowsContentJavaScript = false
1602        config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1603        let webView = WKWebView(frame: .zero, configuration: config)
1604        webView.isOpaque = false
1605        webView.backgroundColor = .clear
1606        webView.clipsToBounds = false
1607        webView.allowsLinkPreview = false
1608        webView.scrollView.isScrollEnabled = false
1609        webView.scrollView.contentInsetAdjustmentBehavior = .never
1610        webView.scrollView.clipsToBounds = false
1611        webView.navigationDelegate = context.coordinator
1612        return webView
1613    }
1614
1615    func updateUIView(_ webView: WKWebView, context: Context) {
1616        context.coordinator.parent = self
1617        let textColor = colorScheme == .dark ? "#fff" : "#000"
1618        let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1619
1620        let wrapped = """
1621        <!DOCTYPE html>
1622        <html>
1623        <head>
1624        <meta name="viewport" content="\(style.viewport)">
1625        <style>
1626            body {
1627                font-family: -apple-system, system-ui, sans-serif;
1628                font-size: \(style.bodyFontSize)px;
1629                line-height: \(style.lineHeight);
1630                padding: 0;
1631                margin: 0;
1632                color: \(textColor);
1633                background: transparent;
1634                word-wrap: break-word;
1635                overflow-wrap: break-word;
1636                max-width: 100%;
1637            }
1638            * { box-sizing: border-box; }
1639            h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1640            p:first-child { margin-top: 0; }
1641            p:last-child { margin-bottom: 0; }
1642            pre, code {
1643                font-family: ui-monospace, Menlo, monospace;
1644                font-size: \(style.codeFontSize)px;
1645                background: rgba(128, 128, 128, 0.15);
1646                padding: 2px 4px;
1647                border-radius: 3px;
1648            }
1649            pre code { padding: 0; background: none; }
1650            pre {
1651                padding: 8px;
1652                overflow-x: auto;
1653                white-space: pre;
1654                word-break: normal;
1655                overflow-wrap: normal;
1656            }
1657            img { max-width: 100%; height: auto; }
1658            svg {
1659                max-width: 100%;
1660                height: auto;
1661            }
1662            input[type="checkbox"] {
1663                margin-right: 0.45rem;
1664                vertical-align: middle;
1665            }
1666            .task-list-item {
1667                display: inline-flex;
1668                align-items: center;
1669                gap: 0.1rem;
1670            }
1671            a { color: \(linkColor); }
1672            table { border-collapse: collapse; width: 100%; }
1673            td, th { border: 1px solid #ccc; padding: 4px 8px; }
1674            blockquote {
1675                border-left: 3px solid rgba(128, 128, 128, 0.5);
1676                margin: 0.5em 0;
1677                padding: 0.25em 0 0.25em 1em;
1678                color: inherit;
1679                opacity: 0.85;
1680            }
1681            .org-verse {
1682                white-space: pre-wrap;
1683            }
1684            hr {
1685                border: none;
1686                border-top: 1px solid rgba(128, 128, 128, 0.35);
1687                margin: 1em 0;
1688            }
1689            table {
1690                border-collapse: collapse;
1691                width: 100%;
1692                margin: 0.75em 0;
1693                font-size: 0.95em;
1694            }
1695            th {
1696                background: rgba(128, 128, 128, 0.15);
1697                font-weight: 600;
1698                text-align: left;
1699            }
1700            td, th {
1701                border: 1px solid rgba(128, 128, 128, 0.3);
1702                padding: 6px 10px;
1703            }
1704            dl.org-properties {
1705                margin: 0.5em 0;
1706                display: grid;
1707                grid-template-columns: max-content 1fr;
1708                gap: 2px 12px;
1709            }
1710            dt {
1711                font-weight: 600;
1712                font-family: ui-monospace, Menlo, monospace;
1713                font-size: 0.9em;
1714            }
1715            dd { margin: 0; }
1716            .org-metadata { margin-bottom: 1em; }
1717            figure.org-block {
1718                margin: 0.75em 0;
1719            }
1720            figure.org-block figcaption {
1721                margin-top: 0.4em;
1722                color: rgba(128, 128, 128, 0.85);
1723                font-size: 0.9em;
1724            }
1725            .btn {
1726                display: inline-flex;
1727                align-items: center;
1728                gap: 0.4em;
1729            }
1730            .icon {
1731                display: inline-flex;
1732                align-items: center;
1733                vertical-align: middle;
1734            }
1735            .icon svg {
1736                width: 0.65em;
1737                height: 0.65em;
1738                display: block;
1739                fill: currentColor;
1740            }
1741            .org-title { margin: 0 0 0.25em; }
1742            .org-author, .org-date {
1743                margin: 0;
1744                color: rgba(128, 128, 128, 0.85);
1745                font-size: 0.9em;
1746            }
1747            del { opacity: 0.7; }
1748        </style>
1749        </head>
1750        <body>\(html)</body>
1751        </html>
1752        """
1753
1754        if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1755            let height = CGFloat(cachedHeight)
1756            if abs(dynamicHeight - height) > 0.5 {
1757                DispatchQueue.main.async {
1758                    if abs(self.dynamicHeight - height) > 0.5 {
1759                        self.dynamicHeight = height
1760                    }
1761                }
1762            }
1763        }
1764
1765        guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1766        context.coordinator.lastHTML = wrapped
1767        context.coordinator.lastReloadToken = reloadToken
1768        if loadError != nil {
1769            DispatchQueue.main.async {
1770                self.loadError = nil
1771            }
1772        }
1773        webView.loadHTMLString(wrapped, baseURL: baseURL)
1774    }
1775}
1776
1777private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1778    static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1779    static let heightCache = NSCache<NSString, NSNumber>()
1780
1781    var parent: HTMLWebViewRepresentable
1782    var lastHTML: String?
1783    var lastReloadToken = 0
1784
1785    init(parent: HTMLWebViewRepresentable) {
1786        self.parent = parent
1787    }
1788
1789    func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1790        DispatchQueue.main.async {
1791            self.parent.loadError = nil
1792        }
1793        updateHeight(for: webView)
1794    }
1795
1796    func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1797        handleLoadFailure(error)
1798    }
1799
1800    func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1801        handleLoadFailure(error)
1802    }
1803
1804    func webView(
1805        _ webView: WKWebView,
1806        decidePolicyFor navigationAction: WKNavigationAction,
1807        decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1808    ) {
1809        guard let requestURL = navigationAction.request.url else {
1810            decisionHandler(.allow)
1811            return
1812        }
1813
1814        if navigationAction.navigationType == .linkActivated {
1815            if isSameDocumentFragmentNavigation(requestURL) {
1816                decisionHandler(.allow)
1817                return
1818            }
1819            if let intercept = parent.onInterceptURL, intercept(requestURL) {
1820                decisionHandler(.cancel)
1821                return
1822            }
1823            if isAllowedReadmeNavigationURL(requestURL) {
1824                parent.openURL(requestURL)
1825            }
1826            decisionHandler(.cancel)
1827            return
1828        }
1829
1830        if isAllowedReadmeNavigationURL(requestURL) {
1831            decisionHandler(.allow)
1832        } else {
1833            decisionHandler(.cancel)
1834        }
1835    }
1836
1837    private func handleLoadFailure(_ error: Error) {
1838        let nsError = error as NSError
1839        guard nsError.code != NSURLErrorCancelled else { return }
1840        DispatchQueue.main.async {
1841            self.parent.loadError = "The content could not be displayed right now."
1842        }
1843    }
1844
1845    private func updateHeight(for webView: WKWebView) {
1846        webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1847            guard let self else { return }
1848            guard let heightValue = result as? NSNumber else { return }
1849            let height = CGFloat(heightValue.doubleValue)
1850            guard height > 0 else { return }
1851            let rounded = ceil(height) + 4
1852            DispatchQueue.main.async {
1853                if let html = self.lastHTML {
1854                    Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1855                }
1856                if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1857                    self.parent.dynamicHeight = rounded
1858                }
1859            }
1860        }
1861    }
1862
1863    private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
1864        guard url.fragment != nil,
1865              let baseURL = parent.baseURL else {
1866            return false
1867        }
1868
1869        guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
1870              var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
1871            return false
1872        }
1873
1874        destination.fragment = nil
1875        base.fragment = nil
1876        return destination.url == base.url
1877    }
1878}