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