Commit db74be7ec4

db74be7ec4e5c54cc18ae94e50b42ded20c9e516

parent: 6a8dd2561e

Unsigned

cmc <hello@cleberg.net> · 2026-04-03 20:18 UTC

feat: add in-app man pages browser

Add a native Man Pages section to the More tab with in-app browsing for
man.sr.ht documentation and srht.site pages.

- add ManPageService for fetching and extracting public docs content
- add ManPageBrowserView and ManPageDetailView
- add More tab navigation for Man Pages
- support in-place navigation for internal documentation links
- allow HTMLWebView to intercept internal links and use a base URL
- support man.sr.ht and srht.site content extraction
- remove heading permalink "#" anchors from man page rendering
- fix relative links, fragment links, and srht.site CTA/icon layout

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -539,7 +539,7 @@
539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541 CODE_SIGN_STYLE = Automatic; 541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 23; 542 CURRENT_PROJECT_VERSION = 24;
543 DEVELOPMENT_TEAM = ZCNAX3VL9D; 543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544 ENABLE_PREVIEWS = YES; 544 ENABLE_PREVIEWS = YES;
545 GENERATE_INFOPLIST_FILE = YES; 545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556 "$(inherited)", 556 "$(inherited)",
557 "@executable_path/Frameworks", 557 "@executable_path/Frameworks",
558 ); 558 );
559 MARKETING_VERSION = 2.9.1; 559 MARKETING_VERSION = 2.10.0;
560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561 PRODUCT_NAME = "$(TARGET_NAME)"; 561 PRODUCT_NAME = "$(TARGET_NAME)";
562 STRING_CATALOG_GENERATE_SYMBOLS = YES; 562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578 CODE_SIGN_STYLE = Automatic; 578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 23; 579 CURRENT_PROJECT_VERSION = 24;
580 DEVELOPMENT_TEAM = ZCNAX3VL9D; 580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581 ENABLE_PREVIEWS = YES; 581 ENABLE_PREVIEWS = YES;
582 GENERATE_INFOPLIST_FILE = YES; 582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593 "$(inherited)", 593 "$(inherited)",
594 "@executable_path/Frameworks", 594 "@executable_path/Frameworks",
595 ); 595 );
596 MARKETING_VERSION = 2.9.1; 596 MARKETING_VERSION = 2.10.0;
597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598 PRODUCT_NAME = "$(TARGET_NAME)"; 598 PRODUCT_NAME = "$(TARGET_NAME)";
599 STRING_CATALOG_GENERATE_SYMBOLS = YES; 599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656 APPLICATION_EXTENSION_API_ONLY = YES; 656 APPLICATION_EXTENSION_API_ONLY = YES;
657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658 CODE_SIGN_STYLE = Automatic; 658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 23; 659 CURRENT_PROJECT_VERSION = 24;
660 DEVELOPMENT_TEAM = ZCNAX3VL9D; 660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661 GENERATE_INFOPLIST_FILE = NO; 661 GENERATE_INFOPLIST_FILE = NO;
662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666 "@executable_path/Frameworks", 666 "@executable_path/Frameworks",
667 "@executable_path/../../Frameworks", 667 "@executable_path/../../Frameworks",
668 ); 668 );
669 MARKETING_VERSION = 2.9.1; 669 MARKETING_VERSION = 2.10.0;
670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671 PRODUCT_NAME = "$(TARGET_NAME)"; 671 PRODUCT_NAME = "$(TARGET_NAME)";
672 SKIP_INSTALL = YES; 672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685 APPLICATION_EXTENSION_API_ONLY = YES; 685 APPLICATION_EXTENSION_API_ONLY = YES;
686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687 CODE_SIGN_STYLE = Automatic; 687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 23; 688 CURRENT_PROJECT_VERSION = 24;
689 DEVELOPMENT_TEAM = ZCNAX3VL9D; 689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690 GENERATE_INFOPLIST_FILE = NO; 690 GENERATE_INFOPLIST_FILE = NO;
691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695 "@executable_path/Frameworks", 695 "@executable_path/Frameworks",
696 "@executable_path/../../Frameworks", 696 "@executable_path/../../Frameworks",
697 ); 697 );
698 MARKETING_VERSION = 2.9.1; 698 MARKETING_VERSION = 2.10.0;
699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700 PRODUCT_NAME = "$(TARGET_NAME)"; 700 PRODUCT_NAME = "$(TARGET_NAME)";
701 SKIP_INSTALL = YES; 701 SKIP_INSTALL = YES;
Hutch/App/RootView.swift +6
@@ -269,6 +269,8 @@ enum MoreRoute: Hashable {
269 case settings 269 case settings
270 case mailingList(InboxMailingListReference) 270 case mailingList(InboxMailingListReference)
271 case thread(InboxThreadSummary) 271 case thread(InboxThreadSummary)
272 case manPageBrowser
273 case manPage(URL)
272} 274}
273 275
274private struct MoreNavigationRoot: View { 276private struct MoreNavigationRoot: View {
@@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View {
302 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 304 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
303 } 305 }
304 ) 306 )
307 case .manPageBrowser:
308 ManPageBrowserView()
309 case .manPage(let url):
310 ManPageDetailView(url: url)
305 } 311 }
306 } 312 }
307 } 313 }
Hutch/Networking/ManPageService.swift added +216
@@ -0,0 +1,216 @@
1import Foundation
2
3/// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required).
4struct ManPage: Sendable {
5 let url: URL
6 let title: String
7 let contentHTML: String
8}
9
10struct ManPageService {
11 static let baseURL = URL(string: "https://man.sr.ht")!
12 static let pagesBaseURL = URL(string: "https://srht.site/")!
13
14 /// Fetches a man.sr.ht page and extracts the article content.
15 /// Uses an unauthenticated URLSession because man.sr.ht pages are public.
16 static func fetch(url: URL) async throws -> ManPage {
17 guard isTrustedDocumentationURL(url) else {
18 throw URLError(.badURL)
19 }
20
21 let (data, response) = try await URLSession.shared.data(from: url)
22 if let http = response as? HTTPURLResponse,
23 !(200...299).contains(http.statusCode) {
24 throw URLError(.badServerResponse)
25 }
26 guard let html = String(data: data, encoding: .utf8) else {
27 throw URLError(.cannotDecodeContentData)
28 }
29
30 return ManPage(
31 url: url,
32 title: extractTitle(from: html, fallbackURL: url),
33 contentHTML: sanitizeContentHTML(extractContent(from: html))
34 )
35 }
36
37 static func isTrustedDocumentationURL(_ url: URL) -> Bool {
38 guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
39 let host = url.host?.lowercased() else {
40 return false
41 }
42
43 return host == "man.sr.ht"
44 || host.hasSuffix(".man.sr.ht")
45 || host == "srht.site"
46 }
47
48 private static func extractTitle(from html: String, fallbackURL: URL) -> String {
49 if let headerHTML = substring(
50 in: html,
51 startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"#
52 ),
53 let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) {
54 let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines)
55 if !title.isEmpty {
56 return title
57 }
58 }
59
60 if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) {
61 let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines)
62 let suffix = " - man.sr.ht"
63 let normalizedTitle: String
64 if rawTitle.hasSuffix(suffix) {
65 normalizedTitle = String(rawTitle.dropLast(suffix.count))
66 } else {
67 normalizedTitle = rawTitle
68 }
69
70 if !normalizedTitle.isEmpty {
71 return normalizedTitle
72 }
73 }
74
75 let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? ""
76 return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent
77 }
78
79 private static func extractContent(from html: String) -> String {
80 if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty {
81 return content
82 }
83
84 if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty {
85 return content
86 }
87
88 if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty {
89 return content
90 }
91
92 return ""
93 }
94
95 private static func sanitizeContentHTML(_ html: String) -> String {
96 html.replacingOccurrences(
97 of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###,
98 with: "",
99 options: [.regularExpression, .caseInsensitive]
100 )
101 }
102
103 private static func extractArticleBlock(from html: String, className: String) -> String? {
104 extractElementBlock(from: html, elementName: "article", className: className)
105 }
106
107 private static func extractDivBlock(from html: String, className: String) -> String? {
108 extractElementBlock(from: html, elementName: "div", className: className)
109 }
110
111 private static func extractElementBlock(
112 from html: String,
113 elementName: String,
114 className: String
115 ) -> String? {
116 guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else {
117 return nil
118 }
119
120 let characters = Array(html)
121 var index = html.distance(from: html.startIndex, to: startRange.lowerBound)
122 var depth = 0
123 var foundOpeningDiv = false
124
125 while index < characters.count {
126 guard characters[index] == "<" else {
127 index += 1
128 continue
129 }
130
131 if hasPrefix("</\(elementName)", at: index, in: characters) {
132 if foundOpeningDiv {
133 depth -= 1
134 if depth == 0 {
135 let closeEnd = endOfTag(startingAt: index, in: characters)
136 return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd])
137 }
138 }
139 index += 1
140 continue
141 }
142
143 if hasPrefix("<\(elementName)", at: index, in: characters) {
144 if !isSelfClosingTag(startingAt: index, in: characters) {
145 depth += 1
146 foundOpeningDiv = true
147 }
148 index += 1
149 continue
150 }
151
152 index += 1
153 }
154
155 return nil
156 }
157
158 private static func firstMatch(in text: String, pattern: String) -> String? {
159 guard let regex = try? NSRegularExpression(
160 pattern: pattern,
161 options: [.caseInsensitive, .dotMatchesLineSeparators]
162 ) else {
163 return nil
164 }
165
166 let range = NSRange(text.startIndex..., in: text)
167 guard let match = regex.firstMatch(in: text, range: range),
168 match.numberOfRanges > 1,
169 let captureRange = Range(match.range(at: 1), in: text) else {
170 return nil
171 }
172
173 return String(text[captureRange])
174 }
175
176 private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? {
177 guard let range = text.range(of: needle) else {
178 return nil
179 }
180
181 return String(text[range.lowerBound...])
182 }
183
184 private static func stripHTML(from text: String) -> String {
185 let noTags = text.replacingOccurrences(
186 of: #"<[^>]+>"#,
187 with: "",
188 options: .regularExpression
189 )
190
191 return decodeHTMLEntities(noTags)
192 }
193
194 private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool {
195 guard index + prefix.count <= characters.count else { return false }
196 return String(characters[index..<(index + prefix.count)]).lowercased() == prefix
197 }
198
199 private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool {
200 let tagEnd = endOfTag(startingAt: index, in: characters)
201 guard tagEnd > index else { return false }
202 let tagContents = String(characters[index..<tagEnd])
203 return tagContents.contains("/>")
204 }
205
206 private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int {
207 var current = index
208 while current < characters.count {
209 if characters[current] == ">" {
210 return current + 1
211 }
212 current += 1
213 }
214 return characters.count
215 }
216}
Hutch/Views/Lookup/LookupView.swift +4
@@ -449,6 +449,10 @@ struct LookupView: View {
449 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 449 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
450 } 450 }
451 ) 451 )
452 case .manPageBrowser:
453 ManPageBrowserView()
454 case .manPage(let url):
455 ManPageDetailView(url: url)
452 } 456 }
453 } 457 }
454 .environment(appState) 458 .environment(appState)
Hutch/Views/More/ManPageBrowserView.swift added +33
@@ -0,0 +1,33 @@
1import SwiftUI
2
3/// Entry point for the man.sr.ht browser in the More tab.
4/// Shows a pre-populated list of official sr.ht man pages.
5struct ManPageBrowserView: View {
6 private let officialDocs: [(title: String, url: URL)] = [
7 ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!),
8 ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!),
9 ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!),
10 ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!),
11 ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!),
12 ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!),
13 ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!),
14 ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!),
15 ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!),
16 ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!),
17 ("srht.site", URL(string: "https://srht.site/")!)
18 ]
19
20 var body: some View {
21 List {
22 Section("Official Man Pages") {
23 ForEach(officialDocs, id: \.title) { doc in
24 NavigationLink(value: MoreRoute.manPage(doc.url)) {
25 Text(doc.title)
26 }
27 }
28 }
29 }
30 .navigationTitle("Man Pages")
31 .navigationBarTitleDisplayMode(.inline)
32 }
33}
Hutch/Views/More/ManPageDetailView.swift added +103
@@ -0,0 +1,103 @@
1import SwiftUI
2
3/// Fetches and renders a single man.sr.ht page.
4/// Internal man.sr.ht links update the current URL in-place rather than
5/// opening the browser, so the user can follow wiki links without leaving
6/// the view.
7struct ManPageDetailView: View {
8 let initialURL: URL
9
10 @Environment(\.colorScheme) private var colorScheme
11 @State private var currentURL: URL
12 @State private var page: ManPage?
13 @State private var isLoading = false
14 @State private var error: String?
15
16 init(url: URL) {
17 initialURL = url
18 _currentURL = State(initialValue: url)
19 }
20
21 var body: some View {
22 ScrollView {
23 if isLoading {
24 SRHTLoadingStateView(message: "Loading page…")
25 .padding(.top, 40)
26 } else if let error {
27 SRHTErrorStateView(
28 title: "Couldn't Load Page",
29 message: error,
30 retryAction: { await loadPage() }
31 )
32 .padding()
33 } else if let page {
34 HTMLWebView(
35 html: page.contentHTML,
36 colorScheme: colorScheme,
37 style: .readme,
38 baseURL: page.url,
39 onInterceptURL: { url in
40 guard let destinationURL = normalizedManPageURL(for: url) else {
41 return false
42 }
43 currentURL = destinationURL
44 return true
45 }
46 )
47 .padding()
48 }
49 }
50 .navigationTitle(page?.title ?? "Documentation")
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar {
53 ToolbarItem(placement: .topBarTrailing) {
54 if let page {
55 Link(destination: page.url) {
56 Image(systemName: "safari")
57 }
58 }
59 }
60 }
61 .task(id: currentURL) {
62 await loadPage()
63 }
64 }
65
66 private func loadPage() async {
67 isLoading = true
68 error = nil
69
70 do {
71 page = try await ManPageService.fetch(url: currentURL)
72 } catch {
73 self.error = error.localizedDescription
74 }
75
76 isLoading = false
77 }
78
79 private func normalizedManPageURL(for url: URL) -> URL? {
80 if ManPageService.isTrustedDocumentationURL(url) {
81 return url
82 }
83
84 guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
85 let scheme = components.scheme?.lowercased(),
86 scheme == "about" || scheme == "file" else {
87 return nil
88 }
89
90 let rawPath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
91 guard !rawPath.isEmpty else {
92 if currentURL.host?.lowercased() == "srht.site" {
93 return ManPageService.pagesBaseURL
94 }
95 return ManPageService.baseURL
96 }
97
98 if currentURL.host?.lowercased() == "srht.site" {
99 return URL(string: "https://srht.site/\(rawPath)") ?? ManPageService.pagesBaseURL
100 }
101 return URL(string: "https://man.sr.ht/\(rawPath)/") ?? ManPageService.baseURL
102 }
103}
Hutch/Views/More/MoreView.swift +4 −1
@@ -5,7 +5,6 @@ struct MoreView: View {
5 5
6 private let unsupportedLinks: [(title: String, url: URL)] = [ 6 private let unsupportedLinks: [(title: String, url: URL)] = [
7 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), 7 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
8 ("man.sr.ht", URL(string: "https://man.sr.ht")!),
9 ("srht.site", URL(string: "https://srht.site")!) 8 ("srht.site", URL(string: "https://srht.site")!)
10 ] 9 ]
11 10
@@ -21,6 +20,10 @@ struct MoreView: View {
21 NavigationLink(value: MoreRoute.lists) { 20 NavigationLink(value: MoreRoute.lists) {
22 Label("Mailing Lists", systemImage: "list.bullet.rectangle") 21 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
23 } 22 }
23
24 NavigationLink(value: MoreRoute.manPageBrowser) {
25 Label("Man Pages", systemImage: "book")
26 }
24 27
25 NavigationLink(value: MoreRoute.pastes) { 28 NavigationLink(value: MoreRoute.pastes) {
26 Label("Pastes", systemImage: "doc.on.clipboard") 29 Label("Pastes", systemImage: "doc.on.clipboard")
Hutch/Views/Repositories/ReadmeView.swift +53 −2
@@ -1522,6 +1522,8 @@ struct HTMLWebView: View {
1522 let html: String 1522 let html: String
1523 let colorScheme: ColorScheme 1523 let colorScheme: ColorScheme
1524 var style: HTMLWebViewStyle = .readme 1524 var style: HTMLWebViewStyle = .readme
1525 var baseURL: URL? = nil
1526 var onInterceptURL: ((URL) -> Bool)? = nil
1525 @Environment(\.openURL) private var openURL 1527 @Environment(\.openURL) private var openURL
1526 @State private var contentHeight: CGFloat = 1 1528 @State private var contentHeight: CGFloat = 1
1527 @State private var loadError: String? 1529 @State private var loadError: String?
@@ -1545,6 +1547,8 @@ struct HTMLWebView: View {
1545 html: html, 1547 html: html,
1546 colorScheme: colorScheme, 1548 colorScheme: colorScheme,
1547 style: style, 1549 style: style,
1550 baseURL: baseURL,
1551 onInterceptURL: onInterceptURL,
1548 openURL: openURL, 1552 openURL: openURL,
1549 dynamicHeight: $contentHeight, 1553 dynamicHeight: $contentHeight,
1550 loadError: $loadError, 1554 loadError: $loadError,
@@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1581 let html: String 1585 let html: String
1582 let colorScheme: ColorScheme 1586 let colorScheme: ColorScheme
1583 let style: HTMLWebViewStyle 1587 let style: HTMLWebViewStyle
1588 let baseURL: URL?
1589 let onInterceptURL: ((URL) -> Bool)?
1584 let openURL: OpenURLAction 1590 let openURL: OpenURLAction
1585 @Binding var dynamicHeight: CGFloat 1591 @Binding var dynamicHeight: CGFloat
1586 @Binding var loadError: String? 1592 @Binding var loadError: String?
@@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1607 } 1613 }
1608 1614
1609 func updateUIView(_ webView: WKWebView, context: Context) { 1615 func updateUIView(_ webView: WKWebView, context: Context) {
1616 context.coordinator.parent = self
1610 let textColor = colorScheme == .dark ? "#fff" : "#000" 1617 let textColor = colorScheme == .dark ? "#fff" : "#000"
1611 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" 1618 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
1612 1619
@@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1648 overflow-wrap: normal; 1655 overflow-wrap: normal;
1649 } 1656 }
1650 img { max-width: 100%; height: auto; } 1657 img { max-width: 100%; height: auto; }
1658 svg {
1659 max-width: 100%;
1660 height: auto;
1661 }
1651 input[type="checkbox"] { 1662 input[type="checkbox"] {
1652 margin-right: 0.45rem; 1663 margin-right: 0.45rem;
1653 vertical-align: middle; 1664 vertical-align: middle;
@@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1711 color: rgba(128, 128, 128, 0.85); 1722 color: rgba(128, 128, 128, 0.85);
1712 font-size: 0.9em; 1723 font-size: 0.9em;
1713 } 1724 }
1725 .btn {
1726 display: inline-flex;
1727 align-items: center;
1728 gap: 0.4em;
1729 }
1730 .icon {
1731 display: inline-flex;
1732 align-items: center;
1733 vertical-align: middle;
1734 }
1735 .icon svg {
1736 width: 0.65em;
1737 height: 0.65em;
1738 display: block;
1739 fill: currentColor;
1740 }
1714 .org-title { margin: 0 0 0.25em; } 1741 .org-title { margin: 0 0 0.25em; }
1715 .org-author, .org-date { 1742 .org-author, .org-date {
1716 margin: 0; 1743 margin: 0;
@@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1743 self.loadError = nil 1770 self.loadError = nil
1744 } 1771 }
1745 } 1772 }
1746 webView.loadHTMLString(wrapped, baseURL: nil) 1773 webView.loadHTMLString(wrapped, baseURL: baseURL)
1747 } 1774 }
1748} 1775}
1749 1776
@@ -1751,7 +1778,7 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1751 static let websiteDataStore = WKWebsiteDataStore.nonPersistent() 1778 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1752 static let heightCache = NSCache<NSString, NSNumber>() 1779 static let heightCache = NSCache<NSString, NSNumber>()
1753 1780
1754 let parent: HTMLWebViewRepresentable 1781 var parent: HTMLWebViewRepresentable
1755 var lastHTML: String? 1782 var lastHTML: String?
1756 var lastReloadToken = 0 1783 var lastReloadToken = 0
1757 1784
@@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1785 } 1812 }
1786 1813
1787 if navigationAction.navigationType == .linkActivated { 1814 if navigationAction.navigationType == .linkActivated {
1815 if isSameDocumentFragmentNavigation(requestURL) {
1816 decisionHandler(.allow)
1817 return
1818 }
1819 if let intercept = parent.onInterceptURL, intercept(requestURL) {
1820 decisionHandler(.cancel)
1821 return
1822 }
1788 if isAllowedReadmeNavigationURL(requestURL) { 1823 if isAllowedReadmeNavigationURL(requestURL) {
1789 parent.openURL(requestURL) 1824 parent.openURL(requestURL)
1790 } 1825 }
@@ -1824,4 +1859,20 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
1824 } 1859 }
1825 } 1860 }
1826 } 1861 }
1862
1863 private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
1864 guard url.fragment != nil,
1865 let baseURL = parent.baseURL else {
1866 return false
1867 }
1868
1869 guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
1870 var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
1871 return false
1872 }
1873
1874 destination.fragment = nil
1875 base.fragment = nil
1876 return destination.url == base.url
1877 }
1827} 1878}