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