From 0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 3 Apr 2026 15:18:38 -0500 Subject: 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 --- Hutch/Views/More/ManPageBrowserView.swift | 33 ++++++++++ Hutch/Views/More/ManPageDetailView.swift | 103 ++++++++++++++++++++++++++++++ Hutch/Views/More/MoreView.swift | 5 +- 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 Hutch/Views/More/ManPageBrowserView.swift create mode 100644 Hutch/Views/More/ManPageDetailView.swift (limited to 'Hutch/Views/More') 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") -- cgit v1.2.3