krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.0: Hutch/Views/Repositories/ReadmeView.swift · raw
1import SwiftUI
2import WebKit
3
4struct ReadmeView: View {
5 let viewModel: RepositoryDetailViewModel
6
7 @Environment(\.colorScheme) private var colorScheme
8 @State private var isShowingRepositoryDetails = false
9
10 var body: some View {
11 ScrollView {
12 VStack(alignment: .leading, spacing: 16) {
13 headerSection
14 metadataSection
15 repositoryDetailsSection
16 latestChangeSection
17 readmeSection
18 }
19 .padding()
20 }
21 .task {
22 async let readme: () = viewModel.loadReadme()
23 async let commits: () = viewModel.loadCommits()
24 async let refs: () = viewModel.loadReferences()
25 _ = await (readme, commits, refs)
26 }
27 .navigationDestination(for: CommitSummary.self) { commit in
28 CommitDetailView(
29 commitSummary: commit,
30 repository: viewModel.repository
31 )
32 }
33 }
34
35 @ViewBuilder
36 private var headerSection: some View {
37 VStack(alignment: .leading, spacing: 6) {
38 Text(viewModel.repository.owner.canonicalName)
39 .font(.subheadline)
40 .foregroundStyle(.secondary)
41 Text(viewModel.repository.name)
42 .font(.largeTitle.weight(.semibold))
43 if let description = viewModel.repository.description, !description.isEmpty {
44 Text(description)
45 .font(.body)
46 }
47 }
48 }
49
50 @ViewBuilder
51 private var metadataSection: some View {
52 VStack(alignment: .leading, spacing: 10) {
53 SummaryMetadataRow(
54 icon: "arrow.triangle.branch",
55 title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
56 )
57
58 if let readmePath = viewModel.readmePath {
59 SummaryMetadataRow(
60 icon: "doc.text",
61 title: readmePath
62 )
63 }
64 }
65 }
66
67 private var repositoryDetailsSection: some View {
68 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
69 VStack(alignment: .leading, spacing: 12) {
70 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
71 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
72 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
73 SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
74 }
75 .padding(.top, 8)
76 } label: {
77 Text("Repository Details")
78 .font(.subheadline.weight(.medium))
79 }
80 }
81
82 @ViewBuilder
83 private var latestChangeSection: some View {
84 VStack(alignment: .leading, spacing: 8) {
85 if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
86 SRHTLoadingStateView(message: "Loading latest change…")
87 .frame(maxWidth: .infinity)
88 } else if let commit = viewModel.commits.first {
89 NavigationLink(value: commit) {
90 SummaryMetadataRow(
91 icon: "arrow.trianglehead.clockwise",
92 title: commit.title,
93 subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
94 )
95 .contentShape(Rectangle())
96 }
97 .buttonStyle(.plain)
98 } else if let error = viewModel.error, viewModel.commits.isEmpty {
99 SRHTErrorStateView(
100 title: "Couldn't Load Latest Change",
101 message: error,
102 retryAction: { await viewModel.loadCommits() }
103 )
104 } else {
105 ContentUnavailableView(
106 "No Recent Commits",
107 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
108 description: Text("This repository does not have any commit history yet.")
109 )
110 }
111 }
112 }
113
114 @ViewBuilder
115 private var readmeSection: some View {
116 if viewModel.isLoadingReadme {
117 SRHTLoadingStateView(message: "Loading README…")
118 } else if let content = viewModel.readmeContent {
119 RenderedMarkupContentView(
120 content: sharedReadmeContent(from: content),
121 readmePath: viewModel.readmePath,
122 colorScheme: colorScheme,
123 ownerCanonicalName: viewModel.repository.owner.canonicalName,
124 repositoryName: viewModel.repository.name
125 )
126 } else if let error = viewModel.error, !viewModel.readmeLoaded {
127 SRHTErrorStateView(
128 title: "Couldn't Load README",
129 message: error,
130 retryAction: { await viewModel.loadReadme() }
131 )
132 } else {
133 ContentUnavailableView(
134 "No README",
135 systemImage: "doc.text",
136 description: Text("This repository does not have a README file.")
137 )
138 }
139 }
140
141 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
142 switch content {
143 case .html(let html):
144 .html(html)
145 case .markdown(let text):
146 .markdown(text)
147 case .org(let text):
148 .org(text)
149 case .plainText(let text):
150 .plainText(text)
151 }
152 }
153}
154
155enum RenderedMarkupContent: Sendable {
156 case html(String)
157 case markdown(String)
158 case org(String)
159 case plainText(String)
160}
161
162struct RenderedMarkupContentView: View {
163 let content: RenderedMarkupContent
164 let readmePath: String?
165 let colorScheme: ColorScheme
166 let ownerCanonicalName: String
167 let repositoryName: String
168 var repositoryHost = "git.sr.ht"
169
170 @State private var renderedHTML: String?
171
172 private var cacheKey: String {
173 switch content {
174 case .html(let html):
175 "html:\(readmePath ?? "custom"):\(html)"
176 case .markdown(let text):
177 "markdown:\(readmePath ?? ""):\(text)"
178 case .org(let text):
179 "org:\(readmePath ?? ""):\(text)"
180 case .plainText(let text):
181 "plain:\(readmePath ?? ""):\(text)"
182 }
183 }
184
185 var body: some View {
186 Group {
187 switch content {
188 case .html(let html):
189 HTMLWebView(html: html, colorScheme: colorScheme)
190 case .markdown, .org:
191 if let renderedHTML {
192 HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
193 } else {
194 SRHTLoadingStateView(message: "Preparing README…")
195 }
196 case .plainText(let text):
197 Text(text)
198 .font(.system(.body, design: .monospaced))
199 .frame(maxWidth: .infinity, alignment: .leading)
200 }
201 }
202 .task(id: cacheKey) {
203 await prepareHTMLIfNeeded()
204 }
205 }
206
207 private func prepareHTMLIfNeeded() async {
208 switch content {
209 case .html, .plainText:
210 renderedHTML = nil
211 case .markdown(let text):
212 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
213 renderedHTML = cached
214 return
215 }
216 let html = await Task.detached(priority: .userInitiated) {
217 markdownToHTML(text) { source in
218 resolveRepositoryAssetURL(
219 source,
220 owner: ownerCanonicalName,
221 repositoryName: repositoryName,
222 readmePath: readmePath
223 )?
224 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
225 }
226 }.value
227 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
228 guard !Task.isCancelled else { return }
229 renderedHTML = html
230 case .org(let text):
231 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
232 renderedHTML = cached
233 return
234 }
235 let html = await Task.detached(priority: .userInitiated) {
236 orgToHTML(text) { source in
237 resolveRepositoryAssetURL(
238 source,
239 owner: ownerCanonicalName,
240 repositoryName: repositoryName,
241 readmePath: readmePath
242 )?
243 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
244 }
245 }.value
246 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
247 guard !Task.isCancelled else { return }
248 renderedHTML = html
249 }
250 }
251}
252
253private final class RenderedReadmeHTMLCache: @unchecked Sendable {
254 static let shared = RenderedReadmeHTMLCache()
255
256 private let storage = NSCache<NSString, NSString>()
257
258 func html(forKey key: String) -> String? {
259 storage.object(forKey: key as NSString) as String?
260 }
261
262 func setHTML(_ html: String, forKey key: String) {
263 storage.setObject(html as NSString, forKey: key as NSString)
264 }
265
266 func removeAll() {
267 storage.removeAllObjects()
268 }
269}
270
271@MainActor
272func clearWebContentRenderCaches() {
273 RenderedReadmeHTMLCache.shared.removeAll()
274 HTMLWebViewCoordinator.heightCache.removeAllObjects()
275}
276
277// MARK: - Markdown to HTML
278
279nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280
281 var protectedFragments: [String: String] = [:]
282 var result = protectMatches(
283 in: text,
284 pattern: #"</?[A-Za-z][^>]*?>"#,
285 protectedFragments: &protectedFragments
286 ) { match, nsText in
287 let rawTag = nsText.substring(with: match.range)
288 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
289 }
290
291 result = escapeHTML(result)
292
293 // Images: 
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(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>"
1005 }
1006 html += "</tr></thead>\n"
1007 }
1008
1009 html += "<tbody>\n"
1010 for row in bodyRows {
1011 html += "<tr>"
1012 for (index, cell) in row.enumerated() {
1013 html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>"
1014 }
1015 html += "</tr>\n"
1016 }
1017 html += "</tbody>\n"
1018 html += "</table>\n"
1019 return html
1020}
1021
1022nonisolated private func columnAlignment(at index: Int, in alignments: [String?]) -> String? {
1023 guard alignments.indices.contains(index) else { return nil }
1024 return alignments[index]
1025}
1026
1027nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1028 guard let alignment, !alignment.isEmpty else { return "" }
1029 return #" style="text-align: \#(alignment);""#
1030}
1031
1032private enum OrgListType: Equatable {
1033 case unordered
1034 case ordered
1035}
1036
1037nonisolated private func orderedListItem(in line: String) -> String? {
1038 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1039 return String(match.2)
1040}
1041
1042nonisolated private func renderOrgListItemBody(
1043 _ lines: [String],
1044 imageURLResolver: ((String) -> String?)? = nil
1045) -> String {
1046 guard let firstLine = lines.first else { return "" }
1047
1048 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1049 var nestedLines: [String] = []
1050
1051 for line in lines.dropFirst() {
1052 let trimmed = line.trimmingCharacters(in: .whitespaces)
1053 if trimmed.isEmpty {
1054 continue
1055 }
1056
1057 if isIndentedListItemLine(line) {
1058 nestedLines.append(outdentOrgListLine(line))
1059 } else {
1060 contentLines.append(trimmed)
1061 }
1062 }
1063
1064 var html = renderTaskListItem(
1065 contentLines.joined(separator: " "),
1066 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
1067 )
1068 if !nestedLines.isEmpty {
1069 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver)
1070 }
1071 return html
1072}
1073
1074nonisolated private func renderNestedOrgListHTML(
1075 _ lines: [String],
1076 imageURLResolver: ((String) -> String?)? = nil
1077) -> String {
1078 var html = ""
1079 var listType: OrgListType?
1080 var currentItemLines: [String] = []
1081
1082 func flushNestedItem() {
1083 guard !currentItemLines.isEmpty else { return }
1084 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n"
1085 currentItemLines = []
1086 }
1087
1088 func closeNestedList() {
1089 flushNestedItem()
1090 switch listType {
1091 case .unordered:
1092 html += "</ul>\n"
1093 case .ordered:
1094 html += "</ol>\n"
1095 case nil:
1096 break
1097 }
1098 listType = nil
1099 }
1100
1101 for line in lines {
1102 let trimmed = line.trimmingCharacters(in: .whitespaces)
1103 if trimmed.hasPrefix("- ") {
1104 if listType != .unordered {
1105 closeNestedList()
1106 html += "<ul>\n"
1107 listType = .unordered
1108 }
1109 flushNestedItem()
1110 currentItemLines = [String(trimmed.dropFirst(2))]
1111 continue
1112 }
1113
1114 if let orderedItem = orderedListItem(in: trimmed) {
1115 if listType != .ordered {
1116 closeNestedList()
1117 html += "<ol>\n"
1118 listType = .ordered
1119 }
1120 flushNestedItem()
1121 currentItemLines = [orderedItem]
1122 continue
1123 }
1124
1125 if listType != nil {
1126 currentItemLines.append(line)
1127 }
1128 }
1129
1130 closeNestedList()
1131 return html
1132}
1133
1134nonisolated private func protectOrgLinks(
1135 in text: String,
1136 protectedFragments: inout [String: String],
1137 imageURLResolver: ((String) -> String?)? = nil
1138) -> String {
1139 var result = text
1140
1141 while let range = result.range(of: "[[") {
1142 guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1143 break
1144 }
1145 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1146 protectedFragments[token] = renderOrgLink(
1147 destination: parsed.destination,
1148 label: parsed.label,
1149 imageURLResolver: imageURLResolver
1150 )
1151 result.replaceSubrange(parsed.range, with: token)
1152 }
1153
1154 return result
1155}
1156
1157nonisolated private func parseOrgLink(
1158 in text: String,
1159 from start: String.Index
1160) -> (range: Range<String.Index>, destination: String, label: String?)? {
1161 guard text[start...].hasPrefix("[[") else { return nil }
1162
1163 var index = text.index(start, offsetBy: 2)
1164 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1165 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1166 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1167 }
1168
1169 let destination = String(text[index..<destinationEnd])
1170 index = text.index(destinationEnd, offsetBy: 2)
1171 let labelStart = index
1172 var depth = 0
1173
1174 while index < text.endIndex {
1175 if text[index...].hasPrefix("[[") {
1176 depth += 1
1177 index = text.index(index, offsetBy: 2)
1178 continue
1179 }
1180 if text[index...].hasPrefix("]]") {
1181 if depth == 0 {
1182 let end = text.index(index, offsetBy: 2)
1183 return (start..<end, destination, String(text[labelStart..<index]))
1184 }
1185 depth -= 1
1186 index = text.index(index, offsetBy: 2)
1187 continue
1188 }
1189 index = text.index(after: index)
1190 }
1191
1192 return nil
1193}
1194
1195nonisolated private func renderOrgLink(
1196 destination: String,
1197 label: String?,
1198 imageURLResolver: ((String) -> String?)? = nil
1199) -> String {
1200 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1201 let source = String(label.dropFirst(2).dropLast(2))
1202 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1203 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1204 return imageHTML
1205 }
1206 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1207 }
1208 }
1209
1210 if let imageHTML = makeOrgImageHTML(
1211 source: destination,
1212 alt: label,
1213 imageURLResolver: imageURLResolver
1214 ) {
1215 return imageHTML
1216 }
1217
1218 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1219 return label ?? destination
1220 }
1221
1222 let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination
1223 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1224}
1225
1226nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1227 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1228 return nil
1229 }
1230 return (
1231 keyword: String(match.1).lowercased(),
1232 value: String(match.2).trimmingCharacters(in: .whitespaces)
1233 )
1234}
1235
1236
1237nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1238 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1239}
1240
1241nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1242 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1243 let range = NSRange(location: 0, length: (text as NSString).length)
1244 return regex.firstMatch(in: text, range: range) != nil
1245}
1246
1247nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1248 guard range.location != NSNotFound else { return false }
1249 let prefix = text.substring(to: range.location)
1250 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
1251 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
1252 return lastOpen > lastClose
1253}
1254
1255nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1256 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1257 guard let first = line.first else { return false }
1258 return first == " " || first == "\t"
1259}
1260
1261nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1262 guard isIndentedContinuationLine(line) else { return false }
1263 let trimmed = line.trimmingCharacters(in: .whitespaces)
1264 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1265}
1266
1267nonisolated private func outdentOrgListLine(_ line: String) -> String {
1268 var result = line
1269 while result.first == " " || result.first == "\t" {
1270 result.removeFirst()
1271 }
1272 return result
1273}
1274
1275private extension Array {
1276 subscript(safe index: Int) -> Element? {
1277 guard indices.contains(index) else { return nil }
1278 return self[index]
1279 }
1280}
1281
1282
1283nonisolated func decodeHTMLEntities(_ text: String) -> String {
1284 text
1285 .replacingOccurrences(of: "&", with: "&")
1286 .replacingOccurrences(of: """, with: "\"")
1287 .replacingOccurrences(of: "'", with: "'")
1288 .replacingOccurrences(of: "<", with: "<")
1289 .replacingOccurrences(of: ">", with: ">")
1290}
1291
1292nonisolated func sanitizedMarkdownHTMLBlock(_ rawHTML: String) -> String? {
1293 var protectedFragments: [String: String] = [:]
1294 var foundUnsafeMarkup = false
1295 let protected = protectMatches(
1296 in: rawHTML,
1297 pattern: #"(?s)<!--.*?-->|</?[A-Za-z][^>]*?>"#,
1298 protectedFragments: &protectedFragments
1299 ) { match, nsText in
1300 let rawTag = nsText.substring(with: match.range)
1301 guard let sanitizedTag = sanitizedMarkdownHTMLTag(rawTag) else {
1302 foundUnsafeMarkup = true
1303 return ""
1304 }
1305 return sanitizedTag
1306 }
1307
1308 guard !foundUnsafeMarkup else { return nil }
1309
1310 var sanitized = escapeHTML(protected)
1311 sanitized = replaceMatches(in: sanitized, pattern: #"ZZPROTECTED\d+ZZ"#) { match, nsText in
1312 let token = nsText.substring(with: match.range)
1313 return protectedFragments[token] ?? ""
1314 }
1315
1316 return sanitized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : sanitized
1317}
1318
1319nonisolated func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1320 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1321 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1322 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
1323
1324 let selfClosing = trimmed.hasSuffix("/>")
1325 let contentStart = trimmed.index(after: trimmed.startIndex)
1326 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
1327 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
1328 let isClosing = inner.hasPrefix("/")
1329 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
1330 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
1331 guard let rawName = parts.first else { return nil }
1332 let tagName = rawName.lowercased()
1333 let allowedTags: Set<String> = [
1334 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1335 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1336 "sup", "u", "ul"
1337 ]
1338 guard allowedTags.contains(tagName) else { return nil }
1339
1340 if isClosing {
1341 return "</\(tagName)>"
1342 }
1343
1344 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1345 let attributes = parseHTMLAttributes(attributePortion)
1346 var renderedAttributes: [String] = []
1347
1348 for (name, value) in attributes {
1349 switch (tagName, name.lowercased()) {
1350 case ("a", "href"):
1351 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1352 renderedAttributes.append(#"href="\#(sanitized)""#)
1353 }
1354 case ("img", "src"):
1355 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1356 renderedAttributes.append(#"src="\#(sanitized)""#)
1357 }
1358 case ("img", "alt"), (_, "title"), (_, "class"):
1359 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1360 default:
1361 continue
1362 }
1363 }
1364
1365 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1366 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1367 return "<\(tagName)\(attributeText)\(suffix)>"
1368}
1369
1370nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1371 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1372 return []
1373 }
1374 let nsText = text as NSString
1375 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1376 let name = nsText.substring(with: match.range(at: 1))
1377 let value = nsText.substring(with: match.range(at: 2))
1378 return (name, value)
1379 }
1380}
1381
1382nonisolated private func protectMatches(
1383 in text: String,
1384 pattern: String,
1385 protectedFragments: inout [String: String],
1386 transform: (NSTextCheckingResult, NSString) -> String
1387) -> String {
1388 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1389 var result = text
1390 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1391
1392 for match in matches.reversed() {
1393 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1394 let nsText = result as NSString
1395 protectedFragments[token] = transform(match, nsText)
1396 result = nsText.replacingCharacters(in: match.range, with: token)
1397 }
1398
1399 return result
1400}
1401
1402nonisolated private func replaceMatches(
1403 in text: String,
1404 pattern: String,
1405 transform: (NSTextCheckingResult, NSString) -> String
1406) -> String {
1407 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
1408 var result = text
1409 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
1410
1411 for match in matches.reversed() {
1412 let nsText = result as NSString
1413 let replacement = transform(match, nsText)
1414 result = nsText.replacingCharacters(in: match.range, with: replacement)
1415 }
1416
1417 return result
1418}
1419
1420nonisolated private func makeOrgImageHTML(
1421 source: String,
1422 alt: String?,
1423 imageURLResolver: ((String) -> String?)?
1424) -> String? {
1425 guard isRenderableImageSource(source) else { return nil }
1426 let resolvedSource = imageURLResolver?(source) ?? source
1427 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1428 let altText = escapeHTMLAttribute(alt ?? "")
1429 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1430}
1431
1432nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1433 let lowercased = source.lowercased()
1434 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1435 .contains(where: { lowercased.hasSuffix($0) })
1436}
1437
1438nonisolated func resolveRepositoryAssetURL(
1439 _ source: String,
1440 owner: String,
1441 repositoryName: String,
1442 readmePath: String?
1443) -> String? {
1444 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
1445 guard !trimmedSource.isEmpty else { return nil }
1446
1447 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
1448 return trimmedSource
1449 }
1450
1451 let relativePath: String
1452 if trimmedSource.hasPrefix("/") {
1453 relativePath = String(trimmedSource.dropFirst())
1454 } else {
1455 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
1456 relativePath = normalizeRepositoryPath(
1457 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
1458 )
1459 }
1460
1461 guard !relativePath.isEmpty else { return nil }
1462 var components = URLComponents()
1463 components.scheme = "https"
1464 components.host = "git.sr.ht"
1465 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1466 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1467 let encodedRelativePath = relativePath
1468 .split(separator: "/", omittingEmptySubsequences: false)
1469 .map { segment in
1470 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1471 }
1472 .joined(separator: "/")
1473 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1474 return components.string
1475}
1476
1477nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1478 var components: [String] = []
1479
1480 for part in path.split(separator: "/") {
1481 switch part {
1482 case ".":
1483 continue
1484 case "..":
1485 if !components.isEmpty {
1486 components.removeLast()
1487 }
1488 default:
1489 components.append(String(part))
1490 }
1491 }
1492
1493 return components.joined(separator: "/")
1494}
1495
1496nonisolated private func renderTaskListItem(
1497 _ text: String,
1498 inlineRenderer: (String) -> String
1499) -> String {
1500 let trimmed = text.trimmingCharacters(in: .whitespaces)
1501 guard trimmed.count >= 4 else {
1502 return inlineRenderer(text)
1503 }
1504
1505 let prefix = String(trimmed.prefix(4))
1506 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1507
1508 switch prefix {
1509 case "[ ] ":
1510 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1511 case "[x] ", "[X] ":
1512 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1513 default:
1514 return inlineRenderer(text)
1515 }
1516}
1517
1518// MARK: - WKWebView Wrapper
1519
1520/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
1521struct HTMLWebView: View {
1522 let html: String
1523 let colorScheme: ColorScheme
1524 var style: HTMLWebViewStyle = .readme
1525 @Environment(\.openURL) private var openURL
1526 @State private var contentHeight: CGFloat = 1
1527 @State private var loadError: String?
1528 @State private var reloadToken = 0
1529
1530 var body: some View {
1531 Group {
1532 if let loadError {
1533 SRHTErrorStateView(
1534 title: "Couldn't Render Content",
1535 message: loadError,
1536 retryAction: {
1537 await MainActor.run {
1538 self.loadError = nil
1539 reloadToken += 1
1540 }
1541 }
1542 )
1543 } else {
1544 HTMLWebViewRepresentable(
1545 html: html,
1546 colorScheme: colorScheme,
1547 style: style,
1548 openURL: openURL,
1549 dynamicHeight: $contentHeight,
1550 loadError: $loadError,
1551 reloadToken: reloadToken
1552 )
1553 .frame(height: max(contentHeight, 1))
1554 }
1555 }
1556 }
1557}
1558
1559struct HTMLWebViewStyle: Sendable {
1560 let bodyFontSize: Int
1561 let lineHeight: Double
1562 let codeFontSize: Int
1563 let viewport: String
1564
1565 static let readme = HTMLWebViewStyle(
1566 bodyFontSize: 16,
1567 lineHeight: 1.6,
1568 codeFontSize: 13,
1569 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
1570 )
1571
1572 static let commentPreview = HTMLWebViewStyle(
1573 bodyFontSize: 15,
1574 lineHeight: 1.5,
1575 codeFontSize: 12,
1576 viewport: "width=device-width, initial-scale=1, user-scalable=no"
1577 )
1578}
1579
1580private struct HTMLWebViewRepresentable: UIViewRepresentable {
1581 let html: String
1582 let colorScheme: ColorScheme
1583 let style: HTMLWebViewStyle
1584 let openURL: OpenURLAction
1585 @Binding var dynamicHeight: CGFloat
1586 @Binding var loadError: String?
1587 let reloadToken: Int
1588
1589 func makeCoordinator() -> HTMLWebViewCoordinator {
1590 HTMLWebViewCoordinator(parent: self)
1591 }
1592
1593 func makeUIView(context: Context) -> WKWebView {
1594 let config = WKWebViewConfiguration()
1595 config.defaultWebpagePreferences.allowsContentJavaScript = false
1596 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
1597 let webView = WKWebView(frame: .zero, configuration: config)
1598 webView.isOpaque = false
1599 webView.backgroundColor = .clear
1600 webView.clipsToBounds = false
1601 webView.allowsLinkPreview = false
1602 webView.scrollView.isScrollEnabled = false
1603 webView.scrollView.contentInsetAdjustmentBehavior = .never
1604 webView.scrollView.clipsToBounds = false
1605 webView.navigationDelegate = context.coordinator
1606 return webView
1607 }
1608
1609 func updateUIView(_ webView: WKWebView, context: Context) {
1610 let textColor = colorScheme == .dark ? "#fff" : "#000"
1611 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1612
1613 let wrapped = """
1614 <!DOCTYPE html>
1615 <html>
1616 <head>
1617 <meta name="viewport" content="\(style.viewport)">
1618 <style>
1619 body {
1620 font-family: -apple-system, system-ui, sans-serif;
1621 font-size: \(style.bodyFontSize)px;
1622 line-height: \(style.lineHeight);
1623 padding: 0;
1624 margin: 0;
1625 color: \(textColor);
1626 background: transparent;
1627 word-wrap: break-word;
1628 overflow-wrap: break-word;
1629 max-width: 100%;
1630 }
1631 * { box-sizing: border-box; }
1632 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1633 p:first-child { margin-top: 0; }
1634 p:last-child { margin-bottom: 0; }
1635 pre, code {
1636 font-family: ui-monospace, Menlo, monospace;
1637 font-size: \(style.codeFontSize)px;
1638 background: rgba(128, 128, 128, 0.15);
1639 padding: 2px 4px;
1640 border-radius: 3px;
1641 }
1642 pre code { padding: 0; background: none; }
1643 pre {
1644 padding: 8px;
1645 overflow-x: auto;
1646 white-space: pre;
1647 word-break: normal;
1648 overflow-wrap: normal;
1649 }
1650 img { max-width: 100%; height: auto; }
1651 input[type="checkbox"] {
1652 margin-right: 0.45rem;
1653 vertical-align: middle;
1654 }
1655 .task-list-item {
1656 display: inline-flex;
1657 align-items: center;
1658 gap: 0.1rem;
1659 }
1660 a { color: \(linkColor); }
1661 table { border-collapse: collapse; width: 100%; }
1662 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1663 blockquote {
1664 border-left: 3px solid rgba(128, 128, 128, 0.5);
1665 margin: 0.5em 0;
1666 padding: 0.25em 0 0.25em 1em;
1667 color: inherit;
1668 opacity: 0.85;
1669 }
1670 .org-verse {
1671 white-space: pre-wrap;
1672 }
1673 hr {
1674 border: none;
1675 border-top: 1px solid rgba(128, 128, 128, 0.35);
1676 margin: 1em 0;
1677 }
1678 table {
1679 border-collapse: collapse;
1680 width: 100%;
1681 margin: 0.75em 0;
1682 font-size: 0.95em;
1683 }
1684 th {
1685 background: rgba(128, 128, 128, 0.15);
1686 font-weight: 600;
1687 text-align: left;
1688 }
1689 td, th {
1690 border: 1px solid rgba(128, 128, 128, 0.3);
1691 padding: 6px 10px;
1692 }
1693 dl.org-properties {
1694 margin: 0.5em 0;
1695 display: grid;
1696 grid-template-columns: max-content 1fr;
1697 gap: 2px 12px;
1698 }
1699 dt {
1700 font-weight: 600;
1701 font-family: ui-monospace, Menlo, monospace;
1702 font-size: 0.9em;
1703 }
1704 dd { margin: 0; }
1705 .org-metadata { margin-bottom: 1em; }
1706 figure.org-block {
1707 margin: 0.75em 0;
1708 }
1709 figure.org-block figcaption {
1710 margin-top: 0.4em;
1711 color: rgba(128, 128, 128, 0.85);
1712 font-size: 0.9em;
1713 }
1714 .org-title { margin: 0 0 0.25em; }
1715 .org-author, .org-date {
1716 margin: 0;
1717 color: rgba(128, 128, 128, 0.85);
1718 font-size: 0.9em;
1719 }
1720 del { opacity: 0.7; }
1721 </style>
1722 </head>
1723 <body>\(html)</body>
1724 </html>
1725 """
1726
1727 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1728 let height = CGFloat(cachedHeight)
1729 if abs(dynamicHeight - height) > 0.5 {
1730 DispatchQueue.main.async {
1731 if abs(self.dynamicHeight - height) > 0.5 {
1732 self.dynamicHeight = height
1733 }
1734 }
1735 }
1736 }
1737
1738 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1739 context.coordinator.lastHTML = wrapped
1740 context.coordinator.lastReloadToken = reloadToken
1741 if loadError != nil {
1742 DispatchQueue.main.async {
1743 self.loadError = nil
1744 }
1745 }
1746 webView.loadHTMLString(wrapped, baseURL: nil)
1747 }
1748}
1749
1750private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1751 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1752 static let heightCache = NSCache<NSString, NSNumber>()
1753
1754 let parent: HTMLWebViewRepresentable
1755 var lastHTML: String?
1756 var lastReloadToken = 0
1757
1758 init(parent: HTMLWebViewRepresentable) {
1759 self.parent = parent
1760 }
1761
1762 func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
1763 DispatchQueue.main.async {
1764 self.parent.loadError = nil
1765 }
1766 updateHeight(for: webView)
1767 }
1768
1769 func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
1770 handleLoadFailure(error)
1771 }
1772
1773 func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
1774 handleLoadFailure(error)
1775 }
1776
1777 func webView(
1778 _ webView: WKWebView,
1779 decidePolicyFor navigationAction: WKNavigationAction,
1780 decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
1781 ) {
1782 guard let requestURL = navigationAction.request.url else {
1783 decisionHandler(.allow)
1784 return
1785 }
1786
1787 if navigationAction.navigationType == .linkActivated {
1788 if isAllowedReadmeNavigationURL(requestURL) {
1789 parent.openURL(requestURL)
1790 }
1791 decisionHandler(.cancel)
1792 return
1793 }
1794
1795 if isAllowedReadmeNavigationURL(requestURL) {
1796 decisionHandler(.allow)
1797 } else {
1798 decisionHandler(.cancel)
1799 }
1800 }
1801
1802 private func handleLoadFailure(_ error: Error) {
1803 let nsError = error as NSError
1804 guard nsError.code != NSURLErrorCancelled else { return }
1805 DispatchQueue.main.async {
1806 self.parent.loadError = "The content could not be displayed right now."
1807 }
1808 }
1809
1810 private func updateHeight(for webView: WKWebView) {
1811 webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
1812 guard let self else { return }
1813 guard let heightValue = result as? NSNumber else { return }
1814 let height = CGFloat(heightValue.doubleValue)
1815 guard height > 0 else { return }
1816 let rounded = ceil(height) + 4
1817 DispatchQueue.main.async {
1818 if let html = self.lastHTML {
1819 Self.heightCache.setObject(NSNumber(value: Double(rounded)), forKey: html as NSString)
1820 }
1821 if abs(self.parent.dynamicHeight - rounded) > 0.5 {
1822 self.parent.dynamicHeight = rounded
1823 }
1824 }
1825 }
1826 }
1827}