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