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