import SwiftUI import WebKit struct ReadmeView: View { @Environment(AppState.self) private var appState let viewModel: RepositoryDetailViewModel @Environment(\.colorScheme) private var colorScheme @State private var isShowingRepositoryDetails = false @State private var linkedFile: LinkedFileRequest? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { headerSection metadataSection repositoryDetailsSection latestChangeSection readmeSection if appState.isDebugModeEnabled { debugSection } } .padding() } .sheet(item: $linkedFile) { request in LinkedFileSheetView( rid: viewModel.repository.rid, service: viewModel.repository.service, client: appState.client, request: request ) } .task { async let readme: () = viewModel.loadReadme() async let commits: () = viewModel.loadCommits() async let refs: () = viewModel.loadReferences() _ = await (readme, commits, refs) } .navigationDestination(for: CommitSummary.self) { commit in CommitDetailView( commitSummary: commit, repository: viewModel.repository ) } } @ViewBuilder private var headerSection: some View { VStack(alignment: .leading, spacing: 6) { Text(viewModel.repository.owner.canonicalName) .font(.subheadline) .foregroundStyle(.secondary) Text(viewModel.repository.name) .font(.largeTitle.weight(.semibold)) if let description = viewModel.repository.description, !description.isEmpty { Text(description) .font(.body) } } } @ViewBuilder private var metadataSection: some View { VStack(alignment: .leading, spacing: 10) { if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) { SummaryMetadataRow( icon: "arrow.triangle.branch", title: branchLabel ) } if let readmePath = viewModel.readmePath { SummaryMetadataRow( icon: "doc.text", title: readmePath ) } } } private var repositoryDetailsSection: some View { DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { VStack(alignment: .leading, spacing: 12) { SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service)) SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true) } .padding(.top, 8) } label: { Text("Repository Details") .font(.subheadline.weight(.medium)) } } @ViewBuilder private var latestChangeSection: some View { VStack(alignment: .leading, spacing: 8) { if viewModel.isLoadingCommits && viewModel.commits.isEmpty { SRHTLoadingStateView(message: "Loading latest change…") .frame(maxWidth: .infinity) } else if let commit = viewModel.commits.first { NavigationLink(value: commit) { SummaryMetadataRow( icon: "arrow.trianglehead.clockwise", title: commit.title, subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)" ) .contentShape(Rectangle()) } .buttonStyle(.plain) } else if let error = viewModel.error, viewModel.commits.isEmpty { SRHTErrorStateView( title: "Couldn't Load Latest Change", message: error, retryAction: { await viewModel.loadCommits() } ) } else { ContentUnavailableView( "No Recent Commits", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", description: Text("This repository does not have any commit history yet.") ) } } } @ViewBuilder private var readmeSection: some View { if viewModel.isLoadingReadme { SRHTLoadingStateView(message: "Loading README…") } else if let content = viewModel.readmeContent { RenderedMarkupContentView( content: sharedReadmeContent(from: content), readmePath: viewModel.readmePath, colorScheme: colorScheme, ownerCanonicalName: viewModel.repository.owner.canonicalName, repositoryName: viewModel.repository.name, onInterceptURL: { url in guard let request = parseLinkedFileRequest(url) else { return false } linkedFile = request return true } ) } else if let error = viewModel.error, !viewModel.readmeLoaded { SRHTErrorStateView( title: "Couldn't Load README", message: error, retryAction: { await viewModel.loadReadme() } ) } else { ContentUnavailableView( "No README", systemImage: "doc.text", description: Text("This repository does not have a README file.") ) } } /// Parses a resolved blob URL for this repository and returns a `LinkedFileRequest` /// if the URL matches the pattern `{host}/{owner}/{repo}/blob/{revspec}/{path}`. /// Returns `nil` for any other URL (external links, fragment links, etc.). private func parseLinkedFileRequest(_ url: URL) -> LinkedFileRequest? { let expectedHost = "\(viewModel.repository.service.rawValue).sr.ht" guard let host = url.host, host == expectedHost else { return nil } // pathComponents for https://git.sr.ht/~owner/repo/blob/HEAD/file // → ["/", "~owner", "repo", "blob", "HEAD", "file"] let parts = url.pathComponents guard parts.count >= 6, parts[1] == viewModel.repository.owner.canonicalName, parts[2] == viewModel.repository.name, parts[3] == "blob" else { return nil } let revspec = parts[4] let path = parts[5...].joined(separator: "/") guard !path.isEmpty else { return nil } let fileName = parts.last ?? path return LinkedFileRequest(path: path, revspec: revspec, fileName: fileName) } private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { switch content { case .html(let html): .html(html) case .markdown(let text): .markdown(text) case .org(let text): .org(text) case .plainText(let text): .plainText(text) } } private var debugSection: some View { DebugTextBlock( title: "Debug", content: """ repositoryId: \(viewModel.repository.id) rid: \(viewModel.repository.rid) service: \(viewModel.repository.service.rawValue) defaultBranch: \(viewModel.repository.defaultBranchName ?? "none") webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable") httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable") sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository)) readmePath: \(viewModel.readmePath ?? "none") commitsLoaded: \(viewModel.commits.count) branchesLoaded: \(viewModel.branches.count) tagsLoaded: \(viewModel.tags.count) """ ) } } enum RenderedMarkupContent: Sendable { case html(String) case markdown(String) case org(String) case plainText(String) } struct RenderedMarkupContentView: View { let content: RenderedMarkupContent let readmePath: String? let colorScheme: ColorScheme let ownerCanonicalName: String let repositoryName: String var repositoryHost = "git.sr.ht" var onInterceptURL: ((URL) -> Bool)? = nil @State private var renderedHTML: String? private var cacheKey: String { switch content { case .html(let html): "html:\(readmePath ?? "custom"):\(html)" case .markdown(let text): "markdown:\(readmePath ?? ""):\(text)" case .org(let text): "org:\(readmePath ?? ""):\(text)" case .plainText(let text): "plain:\(readmePath ?? ""):\(text)" } } var body: some View { Group { switch content { case .html(let html): HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL) case .markdown, .org: if let renderedHTML { HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL) } else { SRHTLoadingStateView(message: "Preparing README…") } case .plainText(let text): Text(text) .font(.system(.body, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } } .task(id: cacheKey) { await prepareHTMLIfNeeded() } } private func prepareHTMLIfNeeded() async { switch content { case .html, .plainText: renderedHTML = nil case .markdown(let text): if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { renderedHTML = cached return } let html = await Task.detached(priority: .userInitiated) { markdownToHTML( text, imageURLResolver: { source in resolveRepositoryAssetURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) }, linkURLResolver: { source in resolveRepositoryLinkURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) } ) }.value RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) guard !Task.isCancelled else { return } renderedHTML = html case .org(let text): if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { renderedHTML = cached return } let html = await Task.detached(priority: .userInitiated) { orgToHTML( text, imageURLResolver: { source in resolveRepositoryAssetURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) }, linkURLResolver: { source in resolveRepositoryLinkURL( source, owner: ownerCanonicalName, repositoryName: repositoryName, readmePath: readmePath )? .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) } ) }.value RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) guard !Task.isCancelled else { return } renderedHTML = html } } } private final class RenderedReadmeHTMLCache: @unchecked Sendable { static let shared = RenderedReadmeHTMLCache() private let storage = NSCache() func html(forKey key: String) -> String? { storage.object(forKey: key as NSString) as String? } func setHTML(_ html: String, forKey key: String) { storage.setObject(html as NSString, forKey: key as NSString) } func removeAll() { storage.removeAllObjects() } } @MainActor func clearWebContentRenderCaches() { RenderedReadmeHTMLCache.shared.removeAll() HTMLWebViewCoordinator.heightCache.removeAllObjects() } // MARK: - Markdown to HTML nonisolated func processInline( _ text: String, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { var protectedFragments: [String: String] = [:] var result = protectMatches( in: text, pattern: #"]*?>"#, protectedFragments: &protectedFragments ) { match, nsText in let rawTag = nsText.substring(with: match.range) return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag) } result = escapeHTML(result) // Images: ![alt](url) result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in let alt = nsText.substring(with: match.range(at: 1)) let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) let resolvedSource = imageURLResolver?(source) ?? source guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return escapeHTML(alt) } return #"\#(escapeHTMLAttribute(alt))"# } // Links: [text](url) result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in let label = nsText.substring(with: match.range(at: 1)) let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2))) let resolvedURL = linkURLResolver?(rawURL) ?? rawURL guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedURL) else { return label } return #"\#(label)"# } // Plain email autolinks result = replaceMatches( in: result, pattern: #"(?i)(?\#(email)"# } // Strikethrough: ~~text~~ result = result.replacingOccurrences( of: #"~~(.+?)~~"#, with: "$1", options: .regularExpression ) // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, with: "$1", options: .regularExpression ) // Italic: *text* result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) // Italic: _text_ result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) // Inline code: `text` result = result.replacingOccurrences( of: #"`([^`]+)`"#, with: "$1", options: .regularExpression ) for (token, fragment) in protectedFragments { result = result.replacingOccurrences(of: token, with: fragment) } return result } // MARK: - Org-mode to HTML nonisolated func orgToHTML( _ text: String, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { let normalizedText = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var title: String? var author: String? var date: String? let lines = rawLines.filter { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let directive = orgKeywordDirective(in: trimmed) else { return true } switch directive.keyword { case "title": title = directive.value return false case "author": author = directive.value return false case "date": date = directive.value return false default: return true } } var html = "" var listType: OrgListType? var inQuoteBlock = false var inPropertyDrawer = false var srcLanguage: String? var inExampleBlock = false var inCenterBlock = false var inVerseBlock = false var currentListItemLines: [String] = [] var paragraph: [String] = [] var tableRows: [[String]] = [] var propertyRows: [(String, String)] = [] var verseLines: [String] = [] var pendingBlockName: String? var pendingBlockCaption: String? var activeBlockCaption: String? var isWrappingBlockFigure = false func beginPendingBlockWrapperIfNeeded() { guard pendingBlockName != nil || pendingBlockCaption != nil else { return } let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" html += #"
"# + "\n" activeBlockCaption = pendingBlockCaption isWrappingBlockFigure = true pendingBlockName = nil pendingBlockCaption = nil } func closePendingBlockWrapper() { guard isWrappingBlockFigure else { return } if let activeBlockCaption { html += "
" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "
\n" } html += "
\n" activeBlockCaption = nil isWrappingBlockFigure = false } func flushParagraph() { if !paragraph.isEmpty { let normalizedParagraph = paragraph .map { $0.trimmingCharacters(in: .whitespaces) } .joined(separator: " ") html += "

" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "

\n" paragraph = [] } } func flushListItem() { guard !currentListItemLines.isEmpty else { return } html += "
  • " + renderOrgListItemBody( currentListItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver ) + "
  • \n" currentListItemLines = [] } func closeList() { flushListItem() switch listType { case .unordered: html += "\n" case .ordered: html += "\n" case nil: break } listType = nil } func flushTable() { guard !tableRows.isEmpty else { return } beginPendingBlockWrapperIfNeeded() html += renderHTMLTable( rows: tableRows, inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } ) closePendingBlockWrapper() tableRows = [] } func flushPropertyDrawer() { guard !propertyRows.isEmpty else { return } html += "
    \n" for (key, value) in propertyRows { html += "
    " + escapeHTML(key) + "
    " html += "
    " + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "
    \n" } html += "
    \n" propertyRows = [] } func closeQuoteBlock() { if inQuoteBlock { flushParagraph() html += "\n" inQuoteBlock = false } } func closeSourceBlock() { if srcLanguage != nil { html += "\n" srcLanguage = nil closePendingBlockWrapper() } } func closeExampleBlock() { if inExampleBlock { html += "\n" inExampleBlock = false closePendingBlockWrapper() } } func closeCenterBlock() { if inCenterBlock { flushParagraph() html += "\n" inCenterBlock = false closePendingBlockWrapper() } } func closeVerseBlock() { if inVerseBlock { let content = verseLines .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } .joined(separator: "\n") html += #"
    "# + "\n" html += content + "\n" html += "
    \n" verseLines = [] inVerseBlock = false closePendingBlockWrapper() } } func flushBlockState() { flushParagraph() closeList() flushTable() flushPropertyDrawer() } if title != nil || author != nil || date != nil { html += "
    \n" if let title { html += "

    " + escapeHTML(title) + "

    \n" } if let author { html += "

    " + escapeHTML(author) + "

    \n" } if let date { html += "

    " + escapeHTML(date) + "

    \n" } html += "
    \n" } for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if srcLanguage != nil { if trimmed.lowercased() == "#+end_src" { closeSourceBlock() } else { html += escapeHTML(line) + "\n" } continue } if inExampleBlock { if trimmed.lowercased() == "#+end_example" { closeExampleBlock() } else { html += escapeHTML(line) + "\n" } continue } if inVerseBlock { if trimmed.lowercased() == "#+end_verse" { closeVerseBlock() } else { verseLines.append(line) } continue } if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { closeQuoteBlock() continue } if inCenterBlock { if trimmed.lowercased() == "#+end_center" { closeCenterBlock() } else if trimmed.isEmpty { flushParagraph() } else { paragraph.append(line) } continue } if trimmed == "#" || trimmed.hasPrefix("# ") { continue } if let directive = orgKeywordDirective(in: trimmed) { switch directive.keyword { case "caption": pendingBlockCaption = directive.value continue case "name": pendingBlockName = directive.value continue case "options", "property": continue default: break } } if trimmed.lowercased().hasPrefix("#+begin_src") { closeQuoteBlock() flushBlockState() beginPendingBlockWrapperIfNeeded() let language = trimmed .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) .dropFirst() .first .map(String.init)? .trimmingCharacters(in: .whitespacesAndNewlines) let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" html += "
    "
                srcLanguage = language ?? ""
                continue
            }
    
            if trimmed.lowercased() == "#+begin_example" {
                closeQuoteBlock()
                flushBlockState()
                beginPendingBlockWrapperIfNeeded()
                html += "
    "
                inExampleBlock = true
                continue
            }
    
            if trimmed.lowercased() == "#+begin_quote" {
                flushBlockState()
                beginPendingBlockWrapperIfNeeded()
                html += "
    \n" inQuoteBlock = true continue } if trimmed.lowercased() == "#+begin_center" { closeQuoteBlock() flushBlockState() beginPendingBlockWrapperIfNeeded() html += "
    \n" inCenterBlock = true continue } if trimmed.lowercased() == "#+begin_verse" { closeQuoteBlock() flushBlockState() beginPendingBlockWrapperIfNeeded() verseLines = [] inVerseBlock = true continue } if trimmed == ":PROPERTIES:" { closeQuoteBlock() flushBlockState() inPropertyDrawer = true continue } if trimmed == ":END:", inPropertyDrawer { flushPropertyDrawer() inPropertyDrawer = false continue } if inPropertyDrawer, trimmed.hasPrefix(":"), let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { let keyStart = trimmed.index(after: trimmed.startIndex) let key = String(trimmed[keyStart..\n" continue } if listType != nil && isIndentedContinuationLine(line) { currentListItemLines.append(line) continue } // List items: - item if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { flushParagraph() flushPropertyDrawer() if listType != .unordered { closeList() html += "
      \n" listType = .unordered } flushListItem() currentListItemLines = [String(trimmed.dropFirst(2))] continue } if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { flushParagraph() flushPropertyDrawer() if listType != .ordered { closeList() html += "
        \n" listType = .ordered } flushListItem() currentListItemLines = [orderedItem] continue } // Blank line if trimmed.isEmpty { if inQuoteBlock { flushParagraph() } else { flushBlockState() } continue } // Regular text if pendingBlockName != nil || pendingBlockCaption != nil { pendingBlockName = nil pendingBlockCaption = nil } paragraph.append(line) } closeSourceBlock() closeExampleBlock() closeCenterBlock() closeVerseBlock() closeQuoteBlock() flushBlockState() return html } nonisolated private func processOrgInline( _ text: String, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { var result = escapeHTML(text) var protectedFragments: [String: String] = [:] result = protectMatches( in: result, pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#, protectedFragments: &protectedFragments ) { match, nsText in let destination = nsText.substring(with: match.range(at: 1)) let source = nsText.substring(with: match.range(at: 2)) guard let imageHTML = makeOrgImageHTML( source: source, alt: nil, imageURLResolver: imageURLResolver ) else { return source } let resolvedDestination = linkURLResolver?(destination) ?? destination guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { return imageHTML } return #"\#(imageHTML)"# } result = protectOrgLinks( in: result, protectedFragments: &protectedFragments, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver ) result = protectMatches( in: result, pattern: #"(?\(codeText)" } result = protectMatches( in: result, pattern: #"(?\(value)" } result = protectMatches( in: result, pattern: #"(?\(value)" } // Bold: *text* result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) // Italic: /text/ result = result.replacingOccurrences( of: #"(?$1", options: .regularExpression ) result = replaceMatches( in: result, pattern: #"(?i)(?\#(email)"# } for (token, fragment) in protectedFragments { result = result.replacingOccurrences(of: token, with: fragment) } return result } // MARK: - HTML Escaping nonisolated func escapeHTML(_ text: String) -> String { text.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } nonisolated func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https", "mailto"], allowsFragmentOnly: true ) } nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https"], allowsFragmentOnly: false ) } nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if scheme == "about" || scheme == "data" { return true } guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { return false } return sanitizedURL == escapeHTMLAttribute(url.absoluteString) } nonisolated private func sanitizeReadmeURLString( _ rawURL: String, allowedSchemes: Set, allowsFragmentOnly: Bool ) -> String? { let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedURL.isEmpty else { return nil } if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { return escapeHTMLAttribute(trimmedURL) } guard let components = URLComponents(string: trimmedURL), let scheme = components.scheme?.lowercased(), allowedSchemes.contains(scheme), let sanitizedURL = components.url?.absoluteString else { return nil } return escapeHTMLAttribute(sanitizedURL) } nonisolated private func isTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } nonisolated private func parseTableRow(_ line: String) -> [String] { line .split(separator: "|", omittingEmptySubsequences: false) .dropFirst() .dropLast() .map { String($0).trimmingCharacters(in: .whitespaces) } } nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] { var content = line.trimmingCharacters(in: .whitespaces) if content.hasPrefix("|") { content.removeFirst() } if content.hasSuffix("|") { content.removeLast() } return content .split(separator: "+", omittingEmptySubsequences: false) .map { String($0).trimmingCharacters(in: .whitespaces) } } nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { tableAlignment(for: cell) != nil } nonisolated private func tableAlignment(for cell: String) -> String? { let trimmed = cell.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } let core = trimmed.replacingOccurrences(of: ":", with: "") guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else { return nil } let isLeftAligned = trimmed.hasPrefix(":") let isRightAligned = trimmed.hasSuffix(":") switch (isLeftAligned, isRightAligned) { case (true, true): return "center" case (true, false): return "left" case (false, true): return "right" case (false, false): return "" } } nonisolated private func renderHTMLTable( rows: [[String]], inlineRenderer: (String) -> String ) -> String { guard !rows.isEmpty else { return "" } let separatorCells: [String] if rows.count > 1, rows[1].count == 1 { separatorCells = parseOrgTableSeparatorRow(rows[1][0]) } else { separatorCells = rows.count > 1 ? rows[1] : [] } let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell) let headerRow = rows.first ?? [] let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : [] var html = "\n" if hasHeaderSeparator { html += "" for (index, cell) in headerRow.enumerated() { html += "" + inlineRenderer(cell) + "" } html += "\n" } html += "\n" for row in bodyRows { html += "" for (index, cell) in row.enumerated() { html += "" + inlineRenderer(cell) + "" } html += "\n" } html += "\n" html += "
        \n" return html } nonisolated private func columnAlignment(at index: Int, in alignments: [String?]) -> String? { guard alignments.indices.contains(index) else { return nil } return alignments[index] } nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { guard let alignment, !alignment.isEmpty else { return "" } return #" style="text-align: \#(alignment);""# } private enum OrgListType: Equatable { case unordered case ordered } nonisolated private func orderedListItem(in line: String) -> String? { guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } return String(match.2) } nonisolated private func renderOrgListItemBody( _ lines: [String], imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { guard let firstLine = lines.first else { return "" } var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] var nestedLines: [String] = [] for line in lines.dropFirst() { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { continue } if isIndentedListItemLine(line) { nestedLines.append(outdentOrgListLine(line)) } else { contentLines.append(trimmed) } } var html = renderTaskListItem( contentLines.joined(separator: " "), inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } ) if !nestedLines.isEmpty { html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } return html } nonisolated private func renderNestedOrgListHTML( _ lines: [String], imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { var html = "" var listType: OrgListType? var currentItemLines: [String] = [] func flushNestedItem() { guard !currentItemLines.isEmpty else { return } html += "
      1. " + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "
      2. \n" currentItemLines = [] } func closeNestedList() { flushNestedItem() switch listType { case .unordered: html += "
    \n" case .ordered: html += "\n" case nil: break } listType = nil } for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("- ") { if listType != .unordered { closeNestedList() html += "
      \n" listType = .unordered } flushNestedItem() currentItemLines = [String(trimmed.dropFirst(2))] continue } if let orderedItem = orderedListItem(in: trimmed) { if listType != .ordered { closeNestedList() html += "
        \n" listType = .ordered } flushNestedItem() currentItemLines = [orderedItem] continue } if listType != nil { currentItemLines.append(line) } } closeNestedList() return html } nonisolated private func protectOrgLinks( in text: String, protectedFragments: inout [String: String], imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { var result = text while let range = result.range(of: "[[") { guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else { break } let token = "ZZPROTECTED\(protectedFragments.count)ZZ" protectedFragments[token] = renderOrgLink( destination: parsed.destination, label: parsed.label, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver ) result.replaceSubrange(parsed.range, with: token) } return result } nonisolated private func parseOrgLink( in text: String, from start: String.Index ) -> (range: Range, destination: String, label: String?)? { guard text[start...].hasPrefix("[[") else { return nil } var index = text.index(start, offsetBy: 2) guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } return (start.. String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { if let label, label.hasPrefix("[["), label.hasSuffix("]]") { let source = String(label.dropFirst(2).dropLast(2)) if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { let resolvedDestination = linkURLResolver?(destination) ?? destination guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { return imageHTML } return #"\#(imageHTML)"# } } if let imageHTML = makeOrgImageHTML( source: destination, alt: label, imageURLResolver: imageURLResolver ) { return imageHTML } let resolvedDestination = linkURLResolver?(destination) ?? destination guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { return label ?? destination } let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } ?? destination return #"\#(renderedLabel)"# } nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? { guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else { return nil } return ( keyword: String(match.1).lowercased(), value: String(match.2).trimmingCharacters(in: .whitespaces) ) } nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) } nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool { guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } let range = NSRange(location: 0, length: (text as NSString).length) return regex.firstMatch(in: text, range: range) != nil } nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { guard range.location != NSNotFound else { return false } let prefix = text.substring(to: range.location) guard let lastOpen = prefix.lastIndex(of: "<") else { return false } guard let lastClose = prefix.lastIndex(of: ">") else { return true } return lastOpen > lastClose } nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool { guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } guard let first = line.first else { return false } return first == " " || first == "\t" } nonisolated private func isIndentedListItemLine(_ line: String) -> Bool { guard isIndentedContinuationLine(line) else { return false } let trimmed = line.trimmingCharacters(in: .whitespaces) return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil } nonisolated private func outdentOrgListLine(_ line: String) -> String { var result = line while result.first == " " || result.first == "\t" { result.removeFirst() } return result } private extension Array { subscript(safe index: Int) -> Element? { guard indices.contains(index) else { return nil } return self[index] } } nonisolated func decodeHTMLEntities(_ text: String) -> String { text .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: """, with: "\"") .replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") } nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? { var protectedFragments: [String: String] = [:] var foundUnsafeMarkup = false let protected = protectMatches( in: rawHTML, pattern: #"(?s)|]*?>"#, protectedFragments: &protectedFragments ) { match, nsText in let rawTag = nsText.substring(with: match.range) guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else { foundUnsafeMarkup = true return "" } return sanitizedTag } guard !foundUnsafeMarkup else { return nil } var sanitized = escapeHTML(protected) sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in let token = nsText.substring(with: match.range) return protectedFragments[token] ?? "" } return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized } nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? { let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil } guard !trimmed.lowercased().hasPrefix("