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