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