krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

0914829ad4cfdbe85db9d54c4e5cc1fdd44b82be

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T20:18:38Z

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.xcodeproj/project.pbxproj           |  16 +--
 Hutch/App/RootView.swift                  |   6 +
 Hutch/Networking/ManPageService.swift     | 216 ++++++++++++++++++++++++++++++
 Hutch/Views/Lookup/LookupView.swift       |   4 +
 Hutch/Views/More/ManPageBrowserView.swift |  33 +++++
 Hutch/Views/More/ManPageDetailView.swift  | 103 ++++++++++++++
 Hutch/Views/More/MoreView.swift           |   5 +-
 Hutch/Views/Repositories/ReadmeView.swift |  55 +++++++-
 8 files changed, 427 insertions(+), 11 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index e815185..bef2be0 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -539,7 +539,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 23;
+				CURRENT_PROJECT_VERSION = 24;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.1;
+				MARKETING_VERSION = 2.10.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 23;
+				CURRENT_PROJECT_VERSION = 24;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.1;
+				MARKETING_VERSION = 2.10.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 23;
+				CURRENT_PROJECT_VERSION = 24;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.9.1;
+				MARKETING_VERSION = 2.10.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 23;
+				CURRENT_PROJECT_VERSION = 24;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.9.1;
+				MARKETING_VERSION = 2.10.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
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
+    }
 }