krz/hutch

an ios client for sourcehut

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

v2: 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 markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 280    let normalizedText = text
 281        .replacingOccurrences(of: "\r\n", with: "\n")
 282        .replacingOccurrences(of: "\r", with: "\n")
 283    let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
 284    var html = ""
 285    var inCodeBlock = false
 286    var inList = false
 287    var paragraph: [String] = []
 288
 289    func flushParagraph() {
 290        if !paragraph.isEmpty {
 291            let normalizedParagraph = paragraph
 292                .map { $0.trimmingCharacters(in: .whitespaces) }
 293                .joined(separator: " ")
 294            html += "<p>" + normalizedParagraph + "</p>\n"
 295            paragraph = []
 296        }
 297    }
 298
 299    func closeList() {
 300        if inList {
 301            html += "</ul>\n"
 302            inList = false
 303        }
 304    }
 305
 306    for line in lines {
 307        // Fenced code blocks
 308        if line.hasPrefix("```") {
 309            if inCodeBlock {
 310                html += "</code></pre>\n"
 311                inCodeBlock = false
 312            } else {
 313                flushParagraph()
 314                closeList()
 315                html += "<pre><code>"
 316                inCodeBlock = true
 317            }
 318            continue
 319        }
 320
 321        if inCodeBlock {
 322            html += escapeHTML(line) + "\n"
 323            continue
 324        }
 325
 326        // Headings
 327        if line.hasPrefix("### ") {
 328            flushParagraph()
 329            closeList()
 330            html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
 331            continue
 332        }
 333        if line.hasPrefix("## ") {
 334            flushParagraph()
 335            closeList()
 336            html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
 337            continue
 338        }
 339        if line.hasPrefix("# ") {
 340            flushParagraph()
 341            closeList()
 342            html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
 343            continue
 344        }
 345
 346        // List items
 347        let trimmed = line.trimmingCharacters(in: .whitespaces)
 348        if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
 349            flushParagraph()
 350            if !inList {
 351                html += "<ul>\n"
 352                inList = true
 353            }
 354            html += "<li>" + renderTaskListItem(
 355                String(trimmed.dropFirst(2)),
 356                inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
 357            ) + "</li>\n"
 358            continue
 359        }
 360
 361        // Blank line
 362        if trimmed.isEmpty {
 363            flushParagraph()
 364            closeList()
 365            continue
 366        }
 367
 368        // Regular text  accumulate into paragraph
 369        paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
 370    }
 371
 372    // Flush remaining state
 373    if inCodeBlock {
 374        html += "</code></pre>\n"
 375    }
 376    flushParagraph()
 377    closeList()
 378
 379    return html
 380}
 381
 382nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 383    var result = escapeHTML(text)
 384
 385    // Images: ![alt](url)
 386    result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
 387        let alt = nsText.substring(with: match.range(at: 1))
 388        let source = nsText.substring(with: match.range(at: 2))
 389        let resolvedSource = imageURLResolver?(source) ?? source
 390        guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
 391            return escapeHTML(alt)
 392        }
 393        return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
 394    }
 395    // Links: [text](url)
 396    result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
 397        let label = nsText.substring(with: match.range(at: 1))
 398        let rawURL = nsText.substring(with: match.range(at: 2))
 399        guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
 400            return label
 401        }
 402        return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
 403    }
 404    // Bold: **text**
 405    result = result.replacingOccurrences(
 406        of: #"\*\*(.+?)\*\*"#,
 407        with: "<strong>$1</strong>",
 408        options: .regularExpression
 409    )
 410    // Italic: *text*
 411    result = result.replacingOccurrences(
 412        of: #"\*(.+?)\*"#,
 413        with: "<em>$1</em>",
 414        options: .regularExpression
 415    )
 416    // Inline code: `text`
 417    result = result.replacingOccurrences(
 418        of: #"`([^`]+)`"#,
 419        with: "<code>$1</code>",
 420        options: .regularExpression
 421    )
 422
 423    return result
 424}
 425
 426// MARK: - Org-mode to HTML
 427
 428nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 429    let normalizedText = text
 430        .replacingOccurrences(of: "\r\n", with: "\n")
 431        .replacingOccurrences(of: "\r", with: "\n")
 432    let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
 433    var html = ""
 434    var listType: OrgListType?
 435    var inQuoteBlock = false
 436    var inPropertyDrawer = false
 437    var srcLanguage: String?
 438    var paragraph: [String] = []
 439    var tableRows: [[String]] = []
 440    var propertyRows: [(String, String)] = []
 441
 442    func flushParagraph() {
 443        if !paragraph.isEmpty {
 444            let normalizedParagraph = paragraph
 445                .map { $0.trimmingCharacters(in: .whitespaces) }
 446                .joined(separator: " ")
 447            html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
 448            paragraph = []
 449        }
 450    }
 451
 452    func closeList() {
 453        switch listType {
 454        case .unordered:
 455            html += "</ul>\n"
 456        case .ordered:
 457            html += "</ol>\n"
 458        case nil:
 459            break
 460        }
 461        listType = nil
 462    }
 463
 464    func flushTable() {
 465        guard !tableRows.isEmpty else { return }
 466        let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
 467        let headerRow = tableRows.first ?? []
 468        let bodyRows: [[String]]
 469
 470        html += "<table>\n"
 471        if hasHeaderSeparator {
 472            html += "<thead><tr>"
 473            for cell in headerRow {
 474                html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
 475            }
 476            html += "</tr></thead>\n<tbody>\n"
 477            bodyRows = Array(tableRows.dropFirst(2))
 478        } else {
 479            bodyRows = tableRows
 480        }
 481
 482        for row in bodyRows {
 483            html += "<tr>"
 484            for cell in row {
 485                html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
 486            }
 487            html += "</tr>\n"
 488        }
 489
 490        if hasHeaderSeparator {
 491            html += "</tbody>\n"
 492        }
 493        html += "</table>\n"
 494        tableRows = []
 495    }
 496
 497    func flushPropertyDrawer() {
 498        guard !propertyRows.isEmpty else { return }
 499        html += "<dl class=\"org-properties\">\n"
 500        for (key, value) in propertyRows {
 501            html += "<dt>" + escapeHTML(key) + "</dt>"
 502            html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
 503        }
 504        html += "</dl>\n"
 505        propertyRows = []
 506    }
 507
 508    func closeQuoteBlock() {
 509        if inQuoteBlock {
 510            flushParagraph()
 511            html += "</blockquote>\n"
 512            inQuoteBlock = false
 513        }
 514    }
 515
 516    func closeSourceBlock() {
 517        if srcLanguage != nil {
 518            html += "</code></pre>\n"
 519            srcLanguage = nil
 520        }
 521    }
 522
 523    func flushBlockState() {
 524        flushParagraph()
 525        closeList()
 526        flushTable()
 527        flushPropertyDrawer()
 528    }
 529
 530    for line in lines {
 531        let trimmed = line.trimmingCharacters(in: .whitespaces)
 532
 533        if srcLanguage != nil {
 534            if trimmed.lowercased() == "#+end_src" {
 535                closeSourceBlock()
 536            } else {
 537                html += escapeHTML(line) + "\n"
 538            }
 539            continue
 540        }
 541
 542        if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
 543            closeQuoteBlock()
 544            continue
 545        }
 546
 547        if trimmed.lowercased().hasPrefix("#+begin_src") {
 548            closeQuoteBlock()
 549            flushBlockState()
 550            let language = trimmed
 551                .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
 552                .dropFirst()
 553                .first
 554                .map(String.init)?
 555                .trimmingCharacters(in: .whitespacesAndNewlines)
 556            let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
 557            html += "<pre><code\(classAttribute)>"
 558            srcLanguage = language ?? ""
 559            continue
 560        }
 561
 562        if trimmed.lowercased() == "#+begin_quote" {
 563            flushBlockState()
 564            html += "<blockquote>\n"
 565            inQuoteBlock = true
 566            continue
 567        }
 568
 569        if trimmed == ":PROPERTIES:" {
 570            closeQuoteBlock()
 571            flushBlockState()
 572            inPropertyDrawer = true
 573            continue
 574        }
 575
 576        if trimmed == ":END:", inPropertyDrawer {
 577            flushPropertyDrawer()
 578            inPropertyDrawer = false
 579            continue
 580        }
 581
 582        if inPropertyDrawer,
 583           trimmed.hasPrefix(":"),
 584           let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
 585            let keyStart = trimmed.index(after: trimmed.startIndex)
 586            let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
 587            let valueStart = trimmed.index(after: secondColonIndex)
 588            let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
 589            if !key.isEmpty {
 590                propertyRows.append((key, value))
 591                continue
 592            }
 593        }
 594
 595        if isOrgTableLine(trimmed) {
 596            closeQuoteBlock()
 597            flushParagraph()
 598            closeList()
 599            tableRows.append(parseOrgTableRow(trimmed))
 600            continue
 601        } else {
 602            flushTable()
 603        }
 604
 605        // Org headings: * heading, ** heading, *** heading
 606        if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
 607            closeQuoteBlock()
 608            flushBlockState()
 609            let level = match.1.count
 610            let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
 611            html += "<h\(level)>" + content + "</h\(level)>\n"
 612            continue
 613        }
 614
 615        // List items: - item
 616        if trimmed.hasPrefix("- ") {
 617            flushParagraph()
 618            flushPropertyDrawer()
 619            if listType != .unordered {
 620                closeList()
 621                html += "<ul>\n"
 622                listType = .unordered
 623            }
 624            html += "<li>" + renderTaskListItem(
 625                String(trimmed.dropFirst(2)),
 626                inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
 627            ) + "</li>\n"
 628            continue
 629        }
 630
 631        if let orderedItem = orderedListItem(in: trimmed) {
 632            flushParagraph()
 633            flushPropertyDrawer()
 634            if listType != .ordered {
 635                closeList()
 636                html += "<ol>\n"
 637                listType = .ordered
 638            }
 639            html += "<li>" + renderTaskListItem(
 640                orderedItem,
 641                inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
 642            ) + "</li>\n"
 643            continue
 644        }
 645
 646        // Blank line
 647        if trimmed.isEmpty {
 648            if inQuoteBlock {
 649                flushParagraph()
 650            } else {
 651                flushBlockState()
 652            }
 653            continue
 654        }
 655
 656        // Regular text
 657        paragraph.append(line)
 658    }
 659
 660    closeSourceBlock()
 661    closeQuoteBlock()
 662    flushBlockState()
 663
 664    return html
 665}
 666
 667nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
 668    var result = escapeHTML(text)
 669    var protectedFragments: [String: String] = [:]
 670
 671    result = protectMatches(
 672        in: result,
 673        pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
 674        protectedFragments: &protectedFragments
 675    ) { match, nsText in
 676        let url = nsText.substring(with: match.range(at: 1))
 677        let label = nsText.substring(with: match.range(at: 2))
 678        if let imageHTML = makeOrgImageHTML(
 679            source: url,
 680            alt: label,
 681            imageURLResolver: imageURLResolver
 682        ) {
 683            return imageHTML
 684        }
 685        guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
 686            return label
 687        }
 688        return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
 689    }
 690    result = protectMatches(
 691        in: result,
 692        pattern: #"\[\[([^\]]+)\]\]"#,
 693        protectedFragments: &protectedFragments
 694    ) { match, nsText in
 695        let url = nsText.substring(with: match.range(at: 1))
 696        if let imageHTML = makeOrgImageHTML(
 697            source: url,
 698            alt: nil,
 699            imageURLResolver: imageURLResolver
 700        ) {
 701            return imageHTML
 702        }
 703        guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
 704            return url
 705        }
 706        return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
 707    }
 708    result = protectMatches(
 709        in: result,
 710        pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
 711        protectedFragments: &protectedFragments
 712    ) { match, nsText in
 713        let tildeRange = match.range(at: 1)
 714        let equalsRange = match.range(at: 2)
 715        let codeText: String
 716        if tildeRange.location != NSNotFound {
 717            codeText = nsText.substring(with: tildeRange)
 718        } else {
 719            codeText = nsText.substring(with: equalsRange)
 720        }
 721        return "<code>\(codeText)</code>"
 722    }
 723
 724    // Bold: *text*
 725    result = result.replacingOccurrences(
 726        of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
 727        with: "<strong>$1</strong>",
 728        options: .regularExpression
 729    )
 730    // Italic: /text/
 731    result = result.replacingOccurrences(
 732        of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
 733        with: "<em>$1</em>",
 734        options: .regularExpression
 735    )
 736
 737    for (token, fragment) in protectedFragments {
 738        result = result.replacingOccurrences(of: token, with: fragment)
 739    }
 740
 741    return result
 742}
 743
 744// MARK: - HTML Escaping
 745
 746nonisolated func escapeHTML(_ text: String) -> String {
 747    text.replacingOccurrences(of: "&", with: "&amp;")
 748        .replacingOccurrences(of: "<", with: "&lt;")
 749        .replacingOccurrences(of: ">", with: "&gt;")
 750        .replacingOccurrences(of: "\"", with: "&quot;")
 751}
 752
 753nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
 754    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
 755}
 756
 757nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
 758    sanitizeReadmeURLString(
 759        rawURL,
 760        allowedSchemes: ["http", "https", "mailto"],
 761        allowsFragmentOnly: true
 762    )
 763}
 764
 765nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
 766    sanitizeReadmeURLString(
 767        rawURL,
 768        allowedSchemes: ["http", "https"],
 769        allowsFragmentOnly: false
 770    )
 771}
 772
 773nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
 774    guard let scheme = url.scheme?.lowercased() else {
 775        return false
 776    }
 777    if scheme == "about" || scheme == "data" {
 778        return true
 779    }
 780    guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
 781        return false
 782    }
 783    return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
 784}
 785
 786nonisolated private func sanitizeReadmeURLString(
 787    _ rawURL: String,
 788    allowedSchemes: Set<String>,
 789    allowsFragmentOnly: Bool
 790) -> String? {
 791    let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
 792    guard !trimmedURL.isEmpty else { return nil }
 793
 794    if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
 795        return escapeHTMLAttribute(trimmedURL)
 796    }
 797
 798    guard let components = URLComponents(string: trimmedURL),
 799          let scheme = components.scheme?.lowercased(),
 800          allowedSchemes.contains(scheme),
 801          let sanitizedURL = components.url?.absoluteString else {
 802        return nil
 803    }
 804
 805    return escapeHTMLAttribute(sanitizedURL)
 806}
 807
 808nonisolated private func isOrgTableLine(_ line: String) -> Bool {
 809    line.hasPrefix("|") && line.hasSuffix("|")
 810}
 811
 812nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
 813    line
 814        .split(separator: "|", omittingEmptySubsequences: false)
 815        .dropFirst()
 816        .dropLast()
 817        .map { String($0).trimmingCharacters(in: .whitespaces) }
 818}
 819
 820nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
 821    let trimmed = cell.trimmingCharacters(in: .whitespaces)
 822    return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
 823}
 824
 825private enum OrgListType {
 826    case unordered
 827    case ordered
 828}
 829
 830nonisolated private func orderedListItem(in line: String) -> String? {
 831    guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
 832    return String(match.2)
 833}
 834
 835nonisolated private func protectMatches(
 836    in text: String,
 837    pattern: String,
 838    protectedFragments: inout [String: String],
 839    transform: (NSTextCheckingResult, NSString) -> String
 840) -> String {
 841    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
 842    var result = text
 843    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
 844
 845    for match in matches.reversed() {
 846        let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
 847        let nsText = result as NSString
 848        protectedFragments[token] = transform(match, nsText)
 849        result = nsText.replacingCharacters(in: match.range, with: token)
 850    }
 851
 852    return result
 853}
 854
 855nonisolated private func replaceMatches(
 856    in text: String,
 857    pattern: String,
 858    transform: (NSTextCheckingResult, NSString) -> String
 859) -> String {
 860    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
 861    var result = text
 862    let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
 863
 864    for match in matches.reversed() {
 865        let nsText = result as NSString
 866        let replacement = transform(match, nsText)
 867        result = nsText.replacingCharacters(in: match.range, with: replacement)
 868    }
 869
 870    return result
 871}
 872
 873nonisolated private func makeOrgImageHTML(
 874    source: String,
 875    alt: String?,
 876    imageURLResolver: ((String) -> String?)?
 877) -> String? {
 878    guard isRenderableImageSource(source) else { return nil }
 879    let resolvedSource = imageURLResolver?(source) ?? source
 880    let altText = escapeHTMLAttribute(alt ?? "")
 881    return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
 882}
 883
 884nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
 885    let lowercased = source.lowercased()
 886    return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
 887        .contains(where: { lowercased.hasSuffix($0) })
 888}
 889
 890nonisolated func resolveRepositoryAssetURL(
 891    _ source: String,
 892    owner: String,
 893    repositoryName: String,
 894    readmePath: String?
 895) -> String? {
 896    let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
 897    guard !trimmedSource.isEmpty else { return nil }
 898
 899    if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
 900        return trimmedSource
 901    }
 902
 903    let relativePath: String
 904    if trimmedSource.hasPrefix("/") {
 905        relativePath = String(trimmedSource.dropFirst())
 906    } else {
 907        let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
 908        relativePath = normalizeRepositoryPath(
 909            (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
 910        )
 911    }
 912
 913    guard !relativePath.isEmpty else { return nil }
 914    return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
 915}
 916
 917nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
 918    var components: [String] = []
 919
 920    for part in path.split(separator: "/") {
 921        switch part {
 922        case ".":
 923            continue
 924        case "..":
 925            if !components.isEmpty {
 926                components.removeLast()
 927            }
 928        default:
 929            components.append(String(part))
 930        }
 931    }
 932
 933    return components.joined(separator: "/")
 934}
 935
 936nonisolated private func renderTaskListItem(
 937    _ text: String,
 938    inlineRenderer: (String) -> String
 939) -> String {
 940    let trimmed = text.trimmingCharacters(in: .whitespaces)
 941    guard trimmed.count >= 4 else {
 942        return inlineRenderer(text)
 943    }
 944
 945    let prefix = String(trimmed.prefix(4))
 946    let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
 947
 948    switch prefix {
 949    case "[ ] ":
 950        return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
 951    case "[x] ", "[X] ":
 952        return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
 953    default:
 954        return inlineRenderer(text)
 955    }
 956}
 957
 958// MARK: - WKWebView Wrapper
 959
 960/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
 961struct HTMLWebView: View {
 962    let html: String
 963    let colorScheme: ColorScheme
 964    var style: HTMLWebViewStyle = .readme
 965    @Environment(\.openURL) private var openURL
 966    @State private var contentHeight: CGFloat = 1
 967    @State private var loadError: String?
 968    @State private var reloadToken = 0
 969
 970    var body: some View {
 971        Group {
 972            if let loadError {
 973                SRHTErrorStateView(
 974                    title: "Couldn't Render Content",
 975                    message: loadError,
 976                    retryAction: {
 977                        await MainActor.run {
 978                            self.loadError = nil
 979                            reloadToken += 1
 980                        }
 981                    }
 982                )
 983            } else {
 984                HTMLWebViewRepresentable(
 985                    html: html,
 986                    colorScheme: colorScheme,
 987                    style: style,
 988                    openURL: openURL,
 989                    dynamicHeight: $contentHeight,
 990                    loadError: $loadError,
 991                    reloadToken: reloadToken
 992                )
 993                .frame(height: max(contentHeight, 1))
 994            }
 995        }
 996    }
 997}
 998
 999struct HTMLWebViewStyle: Sendable {
1000    let bodyFontSize: Int
1001    let lineHeight: Double
1002    let codeFontSize: Int
1003    let viewport: String
1004
1005    static let readme = HTMLWebViewStyle(
1006        bodyFontSize: 16,
1007        lineHeight: 1.6,
1008        codeFontSize: 13,
1009        viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1010    )
1011
1012    static let commentPreview = HTMLWebViewStyle(
1013        bodyFontSize: 15,
1014        lineHeight: 1.5,
1015        codeFontSize: 12,
1016        viewport: "width=device-width, initial-scale=1, user-scalable=no"
1017    )
1018}
1019
1020private struct HTMLWebViewRepresentable: UIViewRepresentable {
1021    let html: String
1022    let colorScheme: ColorScheme
1023    let style: HTMLWebViewStyle
1024    let openURL: OpenURLAction
1025    @Binding var dynamicHeight: CGFloat
1026    @Binding var loadError: String?
1027    let reloadToken: Int
1028
1029    func makeCoordinator() -> HTMLWebViewCoordinator {
1030        HTMLWebViewCoordinator(parent: self)
1031    }
1032
1033    func makeUIView(context: Context) -> WKWebView {
1034        let config = WKWebViewConfiguration()
1035        config.defaultWebpagePreferences.allowsContentJavaScript = false
1036        config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1037        let webView = WKWebView(frame: .zero, configuration: config)
1038        webView.isOpaque = false
1039        webView.backgroundColor = .clear
1040        webView.clipsToBounds = false
1041        webView.allowsLinkPreview = false
1042        webView.scrollView.isScrollEnabled = false
1043        webView.scrollView.contentInsetAdjustmentBehavior = .never
1044        webView.scrollView.clipsToBounds = false
1045        webView.navigationDelegate = context.coordinator
1046        return webView
1047    }
1048
1049    func updateUIView(_ webView: WKWebView, context: Context) {
1050        let textColor = colorScheme == .dark ? "#fff" : "#000"
1051        let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1052
1053        let wrapped = """
1054        <!DOCTYPE html>
1055        <html>
1056        <head>
1057        <meta name="viewport" content="\(style.viewport)">
1058        <style>
1059            body {
1060                font-family: -apple-system, system-ui, sans-serif;
1061                font-size: \(style.bodyFontSize)px;
1062                line-height: \(style.lineHeight);
1063                padding: 0;
1064                margin: 0;
1065                color: \(textColor);
1066                background: transparent;
1067                word-wrap: break-word;
1068                overflow-wrap: break-word;
1069                max-width: 100%;
1070            }
1071            * { box-sizing: border-box; }
1072            h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1073            p:first-child { margin-top: 0; }
1074            p:last-child { margin-bottom: 0; }
1075            pre, code {
1076                font-family: ui-monospace, Menlo, monospace;
1077                font-size: \(style.codeFontSize)px;
1078                background: rgba(128, 128, 128, 0.15);
1079                padding: 2px 4px;
1080                border-radius: 3px;
1081            }
1082            pre code { padding: 0; background: none; }
1083            pre {
1084                padding: 8px;
1085                overflow-x: auto;
1086                white-space: pre-wrap;
1087                word-wrap: break-word;
1088            }
1089            img { max-width: 100%; height: auto; }
1090            input[type="checkbox"] {
1091                margin-right: 0.45rem;
1092                vertical-align: middle;
1093            }
1094            .task-list-item {
1095                display: inline-flex;
1096                align-items: center;
1097                gap: 0.1rem;
1098            }
1099            a { color: \(linkColor); }
1100            table { border-collapse: collapse; width: 100%; }
1101            td, th { border: 1px solid #ccc; padding: 4px 8px; }
1102        </style>
1103        </head>
1104        <body>\(html)</body>
1105        </html>
1106        """
1107
1108        if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1109            let height = CGFloat(cachedHeight)
1110            if abs(dynamicHeight - height) > 0.5 {
1111                dynamicHeight = height
1112            }
1113        }
1114
1115        guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1116        context.coordinator.lastHTML = wrapped
1117        context.coordinator.lastReloadToken = reloadToken
1118        if loadError != nil {
1119            DispatchQueue.main.async {
1120                self.loadError = nil
1121            }
1122        }
1123        webView.loadHTMLString(wrapped, baseURL: nil)
1124    }
1125}
1126
1127private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1128    static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1129    static let heightCache = NSCache<NSString, NSNumber>()
1130
1131    let parent: HTMLWebViewRepresentable
1132    var lastHTML: String?
1133    var lastReloadToken = 0
1134
1135    init(parent: HTMLWebViewRepresentable) {
1136        self.parent = parent
1137    }
1138
1139    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1140        DispatchQueue.main.async {
1141            self.parent.loadError = nil
1142        }
1143        updateHeight(for: webView)
1144        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
1145            guard let self, let webView else { return }
1146            self.updateHeight(for: webView)
1147        }
1148    }
1149
1150    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
1151        handleLoadFailure(error)
1152    }
1153
1154    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1155        handleLoadFailure(error)
1156    }
1157
1158    func webView(
1159        _ webView: WKWebView,
1160        decidePolicyFor navigationAction: WKNavigationAction,
1161        decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1162    ) {
1163        guard let requestURL = navigationAction.request.url else {
1164            decisionHandler(.allow)
1165            return
1166        }
1167
1168        if navigationAction.navigationType == .linkActivated {
1169            if isAllowedReadmeNavigationURL(requestURL) {
1170                parent.openURL(requestURL)
1171            }
1172            decisionHandler(.cancel)
1173            return
1174        }
1175
1176        if isAllowedReadmeNavigationURL(requestURL) {
1177            decisionHandler(.allow)
1178        } else {
1179            decisionHandler(.cancel)
1180        }
1181    }
1182
1183    private func handleLoadFailure(_ error: Error) {
1184        let nsError = error as NSError
1185        guard nsError.code != NSURLErrorCancelled else { return }
1186        DispatchQueue.main.async {
1187            self.parent.loadError = "The content could not be displayed right now."
1188        }
1189    }
1190
1191    private func updateHeight(for webView: WKWebView) {
1192        webView.layoutIfNeeded()
1193        let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
1194        guard height > 0 else { return }
1195        DispatchQueue.main.async {
1196            if let html = self.lastHTML {
1197                Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1198            }
1199            if abs(self.parent.dynamicHeight - height) > 0.5 {
1200                self.parent.dynamicHeight = height
1201            }
1202        }
1203    }
1204}