diff options
| author | Christian Cleberg <[email protected]> | 2026-04-03 15:18:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-03 15:18:38 -0500 |
| commit | 0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be (patch) | |
| tree | 4f5c1e7cea0737917e9db3fad458be58827842ba /Hutch | |
| parent | 18a9f62c9acadced60657addb61c2d4d8c1aab17 (diff) | |
| download | hutch-0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be.tar.gz hutch-0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be.tar.bz2 hutch-0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be.zip | |
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
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/RootView.swift | 6 | ||||
| -rw-r--r-- | Hutch/Networking/ManPageService.swift | 216 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/More/ManPageBrowserView.swift | 33 | ||||
| -rw-r--r-- | Hutch/Views/More/ManPageDetailView.swift | 103 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 55 |
7 files changed, 419 insertions, 3 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index a71b247..96c17a8 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -269,6 +269,8 @@ enum MoreRoute: Hashable { case settings case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) + case manPageBrowser + case manPage(URL) } private struct MoreNavigationRoot: View { @@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View { NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) } ) + case .manPageBrowser: + ManPageBrowserView() + case .manPage(let url): + ManPageDetailView(url: url) } } } diff --git a/Hutch/Networking/ManPageService.swift b/Hutch/Networking/ManPageService.swift new file mode 100644 index 0000000..07d5c2d --- /dev/null +++ b/Hutch/Networking/ManPageService.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required). +struct ManPage: Sendable { + let url: URL + let title: String + let contentHTML: String +} + +struct ManPageService { + static let baseURL = URL(string: "https://man.sr.ht")! + static let pagesBaseURL = URL(string: "https://srht.site/")! + + /// Fetches a man.sr.ht page and extracts the article content. + /// Uses an unauthenticated URLSession because man.sr.ht pages are public. + static func fetch(url: URL) async throws -> ManPage { + guard isTrustedDocumentationURL(url) else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, + !(200...299).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + guard let html = String(data: data, encoding: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + + return ManPage( + url: url, + title: extractTitle(from: html, fallbackURL: url), + contentHTML: sanitizeContentHTML(extractContent(from: html)) + ) + } + + static func isTrustedDocumentationURL(_ url: URL) -> Bool { + guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, + let host = url.host?.lowercased() else { + return false + } + + return host == "man.sr.ht" + || host.hasSuffix(".man.sr.ht") + || host == "srht.site" + } + + private static func extractTitle(from html: String, fallbackURL: URL) -> String { + if let headerHTML = substring( + in: html, + startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"# + ), + let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) { + let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + return title + } + } + + if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) { + let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines) + let suffix = " - man.sr.ht" + let normalizedTitle: String + if rawTitle.hasSuffix(suffix) { + normalizedTitle = String(rawTitle.dropLast(suffix.count)) + } else { + normalizedTitle = rawTitle + } + + if !normalizedTitle.isEmpty { + return normalizedTitle + } + } + + let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? "" + return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent + } + + private static func extractContent(from html: String) -> String { + if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty { + return content + } + + if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty { + return content + } + + if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty { + return content + } + + return "" + } + + private static func sanitizeContentHTML(_ html: String) -> String { + html.replacingOccurrences( + of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } + + private static func extractArticleBlock(from html: String, className: String) -> String? { + extractElementBlock(from: html, elementName: "article", className: className) + } + + private static func extractDivBlock(from html: String, className: String) -> String? { + extractElementBlock(from: html, elementName: "div", className: className) + } + + private static func extractElementBlock( + from html: String, + elementName: String, + className: String + ) -> String? { + guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else { + return nil + } + + let characters = Array(html) + var index = html.distance(from: html.startIndex, to: startRange.lowerBound) + var depth = 0 + var foundOpeningDiv = false + + while index < characters.count { + guard characters[index] == "<" else { + index += 1 + continue + } + + if hasPrefix("</\(elementName)", at: index, in: characters) { + if foundOpeningDiv { + depth -= 1 + if depth == 0 { + let closeEnd = endOfTag(startingAt: index, in: characters) + return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd]) + } + } + index += 1 + continue + } + + if hasPrefix("<\(elementName)", at: index, in: characters) { + if !isSelfClosingTag(startingAt: index, in: characters) { + depth += 1 + foundOpeningDiv = true + } + index += 1 + continue + } + + index += 1 + } + + return nil + } + + private static func firstMatch(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return nil + } + + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + match.numberOfRanges > 1, + let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + + return String(text[captureRange]) + } + + private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? { + guard let range = text.range(of: needle) else { + return nil + } + + return String(text[range.lowerBound...]) + } + + private static func stripHTML(from text: String) -> String { + let noTags = text.replacingOccurrences( + of: #"<[^>]+>"#, + with: "", + options: .regularExpression + ) + + return decodeHTMLEntities(noTags) + } + + private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool { + guard index + prefix.count <= characters.count else { return false } + return String(characters[index..<(index + prefix.count)]).lowercased() == prefix + } + + private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool { + let tagEnd = endOfTag(startingAt: index, in: characters) + guard tagEnd > index else { return false } + let tagContents = String(characters[index..<tagEnd]) + return tagContents.contains("/>") + } + + private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int { + var current = index + while current < characters.count { + if characters[current] == ">" { + return current + 1 + } + current += 1 + } + return characters.count + } +} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index bee2453..eb585fb 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -449,6 +449,10 @@ struct LookupView: View { NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) } ) + case .manPageBrowser: + ManPageBrowserView() + case .manPage(let url): + ManPageDetailView(url: url) } } .environment(appState) diff --git a/Hutch/Views/More/ManPageBrowserView.swift b/Hutch/Views/More/ManPageBrowserView.swift new file mode 100644 index 0000000..f854c79 --- /dev/null +++ b/Hutch/Views/More/ManPageBrowserView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Entry point for the man.sr.ht browser in the More tab. +/// Shows a pre-populated list of official sr.ht man pages. +struct ManPageBrowserView: View { + private let officialDocs: [(title: String, url: URL)] = [ + ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!), + ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!), + ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!), + ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!), + ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!), + ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!), + ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!), + ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!), + ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!), + ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!), + ("srht.site", URL(string: "https://srht.site/")!) + ] + + var body: some View { + List { + Section("Official Man Pages") { + ForEach(officialDocs, id: \.title) { doc in + NavigationLink(value: MoreRoute.manPage(doc.url)) { + Text(doc.title) + } + } + } + } + .navigationTitle("Man Pages") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Hutch/Views/More/ManPageDetailView.swift b/Hutch/Views/More/ManPageDetailView.swift new file mode 100644 index 0000000..96a92fb --- /dev/null +++ b/Hutch/Views/More/ManPageDetailView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +/// Fetches and renders a single man.sr.ht page. +/// Internal man.sr.ht links update the current URL in-place rather than +/// opening the browser, so the user can follow wiki links without leaving +/// the view. +struct ManPageDetailView: View { + let initialURL: URL + + @Environment(\.colorScheme) private var colorScheme + @State private var currentURL: URL + @State private var page: ManPage? + @State private var isLoading = false + @State private var error: String? + + init(url: URL) { + initialURL = url + _currentURL = State(initialValue: url) + } + + var body: some View { + ScrollView { + if isLoading { + SRHTLoadingStateView(message: "Loading pageā¦") + .padding(.top, 40) + } else if let error { + SRHTErrorStateView( + title: "Couldn't Load Page", + message: error, + retryAction: { await loadPage() } + ) + .padding() + } else if let page { + HTMLWebView( + html: page.contentHTML, + colorScheme: colorScheme, + style: .readme, + baseURL: page.url, + onInterceptURL: { url in + guard let destinationURL = normalizedManPageURL(for: url) else { + return false + } + currentURL = destinationURL + return true + } + ) + .padding() + } + } + .navigationTitle(page?.title ?? "Documentation") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if let page { + Link(destination: page.url) { + Image(systemName: "safari") + } + } + } + } + .task(id: currentURL) { + await loadPage() + } + } + + private func loadPage() async { + isLoading = true + error = nil + + do { + page = try await ManPageService.fetch(url: currentURL) + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + private func normalizedManPageURL(for url: URL) -> URL? { + if ManPageService.isTrustedDocumentationURL(url) { + return url + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased(), + scheme == "about" || scheme == "file" else { + return nil + } + + let rawPath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !rawPath.isEmpty else { + if currentURL.host?.lowercased() == "srht.site" { + return ManPageService.pagesBaseURL + } + return ManPageService.baseURL + } + + if currentURL.host?.lowercased() == "srht.site" { + return URL(string: "https://srht.site/\(rawPath)") ?? ManPageService.pagesBaseURL + } + return URL(string: "https://man.sr.ht/\(rawPath)/") ?? ManPageService.baseURL + } +} diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 9c5cf92..ae7228d 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -5,7 +5,6 @@ struct MoreView: View { private let unsupportedLinks: [(title: String, url: URL)] = [ ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), - ("man.sr.ht", URL(string: "https://man.sr.ht")!), ("srht.site", URL(string: "https://srht.site")!) ] @@ -21,6 +20,10 @@ struct MoreView: View { NavigationLink(value: MoreRoute.lists) { Label("Mailing Lists", systemImage: "list.bullet.rectangle") } + + NavigationLink(value: MoreRoute.manPageBrowser) { + Label("Man Pages", systemImage: "book") + } NavigationLink(value: MoreRoute.pastes) { Label("Pastes", systemImage: "doc.on.clipboard") diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index 93fee26..5742811 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -1522,6 +1522,8 @@ struct HTMLWebView: View { let html: String let colorScheme: ColorScheme var style: HTMLWebViewStyle = .readme + var baseURL: URL? = nil + var onInterceptURL: ((URL) -> Bool)? = nil @Environment(\.openURL) private var openURL @State private var contentHeight: CGFloat = 1 @State private var loadError: String? @@ -1545,6 +1547,8 @@ struct HTMLWebView: View { html: html, colorScheme: colorScheme, style: style, + baseURL: baseURL, + onInterceptURL: onInterceptURL, openURL: openURL, dynamicHeight: $contentHeight, loadError: $loadError, @@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { let html: String let colorScheme: ColorScheme let style: HTMLWebViewStyle + let baseURL: URL? + let onInterceptURL: ((URL) -> Bool)? let openURL: OpenURLAction @Binding var dynamicHeight: CGFloat @Binding var loadError: String? @@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { } func updateUIView(_ webView: WKWebView, context: Context) { + context.coordinator.parent = self let textColor = colorScheme == .dark ? "#fff" : "#000" let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" @@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { overflow-wrap: normal; } img { max-width: 100%; height: auto; } + svg { + max-width: 100%; + height: auto; + } input[type="checkbox"] { margin-right: 0.45rem; vertical-align: middle; @@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { color: rgba(128, 128, 128, 0.85); font-size: 0.9em; } + .btn { + display: inline-flex; + align-items: center; + gap: 0.4em; + } + .icon { + display: inline-flex; + align-items: center; + vertical-align: middle; + } + .icon svg { + width: 0.65em; + height: 0.65em; + display: block; + fill: currentColor; + } .org-title { margin: 0 0 0.25em; } .org-author, .org-date { margin: 0; @@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { self.loadError = nil } } - webView.loadHTMLString(wrapped, baseURL: nil) + webView.loadHTMLString(wrapped, baseURL: baseURL) } } @@ -1751,7 +1778,7 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc static let websiteDataStore = WKWebsiteDataStore.nonPersistent() static let heightCache = NSCache<NSString, NSNumber>() - let parent: HTMLWebViewRepresentable + var parent: HTMLWebViewRepresentable var lastHTML: String? var lastReloadToken = 0 @@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } if navigationAction.navigationType == .linkActivated { + if isSameDocumentFragmentNavigation(requestURL) { + decisionHandler(.allow) + return + } + if let intercept = parent.onInterceptURL, intercept(requestURL) { + decisionHandler(.cancel) + return + } if isAllowedReadmeNavigationURL(requestURL) { parent.openURL(requestURL) } @@ -1824,4 +1859,20 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } } } + + private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool { + guard url.fragment != nil, + let baseURL = parent.baseURL else { + return false + } + + guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false), + var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return false + } + + destination.fragment = nil + base.fragment = nil + return destination.url == base.url + } } |
