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 @@
539539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 23;
542 CURRENT_PROJECT_VERSION = 24;
543543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544544 ENABLE_PREVIEWS = YES;
545545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556556 "$(inherited)",
557557 "@executable_path/Frameworks",
558558 );
559 MARKETING_VERSION = 2.9.1;
559 MARKETING_VERSION = 2.10.0;
560560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561561 PRODUCT_NAME = "$(TARGET_NAME)";
562562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 23;
579 CURRENT_PROJECT_VERSION = 24;
580580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581581 ENABLE_PREVIEWS = YES;
582582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593593 "$(inherited)",
594594 "@executable_path/Frameworks",
595595 );
596 MARKETING_VERSION = 2.9.1;
596 MARKETING_VERSION = 2.10.0;
597597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598598 PRODUCT_NAME = "$(TARGET_NAME)";
599599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656656 APPLICATION_EXTENSION_API_ONLY = YES;
657657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 23;
659 CURRENT_PROJECT_VERSION = 24;
660660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661661 GENERATE_INFOPLIST_FILE = NO;
662662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666666 "@executable_path/Frameworks",
667667 "@executable_path/../../Frameworks",
668668 );
669 MARKETING_VERSION = 2.9.1;
669 MARKETING_VERSION = 2.10.0;
670670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671671 PRODUCT_NAME = "$(TARGET_NAME)";
672672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685685 APPLICATION_EXTENSION_API_ONLY = YES;
686686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 23;
688 CURRENT_PROJECT_VERSION = 24;
689689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690690 GENERATE_INFOPLIST_FILE = NO;
691691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695695 "@executable_path/Frameworks",
696696 "@executable_path/../../Frameworks",
697697 );
698 MARKETING_VERSION = 2.9.1;
698 MARKETING_VERSION = 2.10.0;
699699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700700 PRODUCT_NAME = "$(TARGET_NAME)";
701701 SKIP_INSTALL = YES;
Hutch/App/RootView.swift +6
@@ -269,6 +269,8 @@ enum MoreRoute: Hashable {
269269 case settings
270270 case mailingList(InboxMailingListReference)
271271 case thread(InboxThreadSummary)
272 case manPageBrowser
273 case manPage(URL)
272274}
273275
274276private struct MoreNavigationRoot: View {
@@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View {
302304 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
303305 }
304306 )
307 case .manPageBrowser:
308 ManPageBrowserView()
309 case .manPage(let url):
310 ManPageDetailView(url: url)
305311 }
306312 }
307313 }
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 {
449449 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
450450 }
451451 )
452 case .manPageBrowser:
453 ManPageBrowserView()
454 case .manPage(let url):
455 ManPageDetailView(url: url)
452456 }
453457 }
454458 .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 {
55
66 private let unsupportedLinks: [(title: String, url: URL)] = [
77 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
8 ("man.sr.ht", URL(string: "https://man.sr.ht")!),
98 ("srht.site", URL(string: "https://srht.site")!)
109 ]
1110
@@ -21,6 +20,10 @@ struct MoreView: View {
2120 NavigationLink(value: MoreRoute.lists) {
2221 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
2322 }
23
24 NavigationLink(value: MoreRoute.manPageBrowser) {
25 Label("Man Pages", systemImage: "book")
26 }
2427
2528 NavigationLink(value: MoreRoute.pastes) {
2629 Label("Pastes", systemImage: "doc.on.clipboard")
Hutch/Views/Repositories/ReadmeView.swift +53 −2
@@ -1522,6 +1522,8 @@ struct HTMLWebView: View {
15221522 let html: String
15231523 let colorScheme: ColorScheme
15241524 var style: HTMLWebViewStyle = .readme
1525 var baseURL: URL? = nil
1526 var onInterceptURL: ((URL) -> Bool)? = nil
15251527 @Environment(\.openURL) private var openURL
15261528 @State private var contentHeight: CGFloat = 1
15271529 @State private var loadError: String?
@@ -1545,6 +1547,8 @@ struct HTMLWebView: View {
15451547 html: html,
15461548 colorScheme: colorScheme,
15471549 style: style,
1550 baseURL: baseURL,
1551 onInterceptURL: onInterceptURL,
15481552 openURL: openURL,
15491553 dynamicHeight: $contentHeight,
15501554 loadError: $loadError,
@@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
15811585 let html: String
15821586 let colorScheme: ColorScheme
15831587 let style: HTMLWebViewStyle
1588 let baseURL: URL?
1589 let onInterceptURL: ((URL) -> Bool)?
15841590 let openURL: OpenURLAction
15851591 @Binding var dynamicHeight: CGFloat
15861592 @Binding var loadError: String?
@@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
16071613 }
16081614
16091615 func updateUIView(_ webView: WKWebView, context: Context) {
1616 context.coordinator.parent = self
16101617 let textColor = colorScheme == .dark ? "#fff" : "#000"
16111618 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
16121619
@@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
16481655 overflow-wrap: normal;
16491656 }
16501657 img { max-width: 100%; height: auto; }
1658 svg {
1659 max-width: 100%;
1660 height: auto;
1661 }
16511662 input[type="checkbox"] {
16521663 margin-right: 0.45rem;
16531664 vertical-align: middle;
@@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
17111722 color: rgba(128, 128, 128, 0.85);
17121723 font-size: 0.9em;
17131724 }
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 }
17141741 .org-title { margin: 0 0 0.25em; }
17151742 .org-author, .org-date {
17161743 margin: 0;
@@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
17431770 self.loadError = nil
17441771 }
17451772 }
1746 webView.loadHTMLString(wrapped, baseURL: nil)
1773 webView.loadHTMLString(wrapped, baseURL: baseURL)
17471774 }
17481775}
17491776
@@ -1751,7 +1778,7 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
17511778 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
17521779 static let heightCache = NSCache<NSString, NSNumber>()
17531780
1754 let parent: HTMLWebViewRepresentable
1781 var parent: HTMLWebViewRepresentable
17551782 var lastHTML: String?
17561783 var lastReloadToken = 0
17571784
@@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
17851812 }
17861813
17871814 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 }
17881823 if isAllowedReadmeNavigationURL(requestURL) {
17891824 parent.openURL(requestURL)
17901825 }
@@ -1824,4 +1859,20 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
18241859 }
18251860 }
18261861 }
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 }
18271878}