Commit db74be7ec4
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | ||
| 539 | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 23; | |
| 542 | CURRENT_PROJECT_VERSION = 24; | |
| 543 | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | 544 | ENABLE_PREVIEWS = YES; |
| 545 | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | ||
| 556 | 556 | "$(inherited)", |
| 557 | 557 | "@executable_path/Frameworks", |
| 558 | 558 | ); |
| 559 | MARKETING_VERSION = 2.9.1; | |
| 559 | MARKETING_VERSION = 2.10.0; | |
| 560 | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | ||
| 576 | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 23; | |
| 579 | CURRENT_PROJECT_VERSION = 24; | |
| 580 | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | 581 | ENABLE_PREVIEWS = YES; |
| 582 | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | ||
| 593 | 593 | "$(inherited)", |
| 594 | 594 | "@executable_path/Frameworks", |
| 595 | 595 | ); |
| 596 | MARKETING_VERSION = 2.9.1; | |
| 596 | MARKETING_VERSION = 2.10.0; | |
| 597 | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | ||
| 656 | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 23; | |
| 659 | CURRENT_PROJECT_VERSION = 24; | |
| 660 | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | ||
| 666 | 666 | "@executable_path/Frameworks", |
| 667 | 667 | "@executable_path/../../Frameworks", |
| 668 | 668 | ); |
| 669 | MARKETING_VERSION = 2.9.1; | |
| 669 | MARKETING_VERSION = 2.10.0; | |
| 670 | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | ||
| 685 | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 23; | |
| 688 | CURRENT_PROJECT_VERSION = 24; | |
| 689 | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | ||
| 695 | 695 | "@executable_path/Frameworks", |
| 696 | 696 | "@executable_path/../../Frameworks", |
| 697 | 697 | ); |
| 698 | MARKETING_VERSION = 2.9.1; | |
| 698 | MARKETING_VERSION = 2.10.0; | |
| 699 | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | 701 | SKIP_INSTALL = YES; |
Hutch/App/RootView.swift +6
| @@ -269,6 +269,8 @@ enum MoreRoute: Hashable { | ||
| 269 | 269 | case settings |
| 270 | 270 | case mailingList(InboxMailingListReference) |
| 271 | 271 | case thread(InboxThreadSummary) |
| 272 | case manPageBrowser | |
| 273 | case manPage(URL) | |
| 272 | 274 | } |
| 273 | 275 | |
| 274 | 276 | private struct MoreNavigationRoot: View { |
| @@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View { | ||
| 302 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required). | |
| 4 | struct ManPage: Sendable { | |
| 5 | let url: URL | |
| 6 | let title: String | |
| 7 | let contentHTML: String | |
| 8 | } | |
| 9 | ||
| 10 | struct 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 | 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 | 458 | .environment(appState) |
Hutch/Views/More/ManPageBrowserView.swift added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import 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. | |
| 5 | struct 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 @@ | ||
| 1 | import 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. | |
| 7 | struct 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 | 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ |
| 7 | 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), |
| 8 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), | |
| 9 | 8 | ("srht.site", URL(string: "https://srht.site")!) |
| 10 | 9 | ] |
| 11 | 10 | |
| @@ -21,6 +20,10 @@ struct MoreView: View { | ||
| 21 | 20 | NavigationLink(value: MoreRoute.lists) { |
| 22 | 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 | 28 | NavigationLink(value: MoreRoute.pastes) { |
| 26 | 29 | Label("Pastes", systemImage: "doc.on.clipboard") |
Hutch/Views/Repositories/ReadmeView.swift +53 −2
| @@ -1522,6 +1522,8 @@ struct HTMLWebView: View { | ||
| 1522 | 1522 | let html: String |
| 1523 | 1523 | let colorScheme: ColorScheme |
| 1524 | 1524 | var style: HTMLWebViewStyle = .readme |
| 1525 | var baseURL: URL? = nil | |
| 1526 | var onInterceptURL: ((URL) -> Bool)? = nil | |
| 1525 | 1527 | @Environment(\.openURL) private var openURL |
| 1526 | 1528 | @State private var contentHeight: CGFloat = 1 |
| 1527 | 1529 | @State private var loadError: String? |
| @@ -1545,6 +1547,8 @@ struct HTMLWebView: View { | ||
| 1545 | 1547 | html: html, |
| 1546 | 1548 | colorScheme: colorScheme, |
| 1547 | 1549 | style: style, |
| 1550 | baseURL: baseURL, | |
| 1551 | onInterceptURL: onInterceptURL, | |
| 1548 | 1552 | openURL: openURL, |
| 1549 | 1553 | dynamicHeight: $contentHeight, |
| 1550 | 1554 | loadError: $loadError, |
| @@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | ||
| 1581 | 1585 | let html: String |
| 1582 | 1586 | let colorScheme: ColorScheme |
| 1583 | 1587 | let style: HTMLWebViewStyle |
| 1588 | let baseURL: URL? | |
| 1589 | let onInterceptURL: ((URL) -> Bool)? | |
| 1584 | 1590 | let openURL: OpenURLAction |
| 1585 | 1591 | @Binding var dynamicHeight: CGFloat |
| 1586 | 1592 | @Binding var loadError: String? |
| @@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | ||
| 1607 | 1613 | } |
| 1608 | 1614 | |
| 1609 | 1615 | func updateUIView(_ webView: WKWebView, context: Context) { |
| 1616 | context.coordinator.parent = self | |
| 1610 | 1617 | let textColor = colorScheme == .dark ? "#fff" : "#000" |
| 1611 | 1618 | let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" |
| 1612 | 1619 | |
| @@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | ||
| 1648 | 1655 | overflow-wrap: normal; |
| 1649 | 1656 | } |
| 1650 | 1657 | img { max-width: 100%; height: auto; } |
| 1658 | svg { | |
| 1659 | max-width: 100%; | |
| 1660 | height: auto; | |
| 1661 | } | |
| 1651 | 1662 | input[type="checkbox"] { |
| 1652 | 1663 | margin-right: 0.45rem; |
| 1653 | 1664 | vertical-align: middle; |
| @@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | ||
| 1711 | 1722 | color: rgba(128, 128, 128, 0.85); |
| 1712 | 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 | 1741 | .org-title { margin: 0 0 0.25em; } |
| 1715 | 1742 | .org-author, .org-date { |
| 1716 | 1743 | margin: 0; |
| @@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | ||
| 1743 | 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 | 1778 | static let websiteDataStore = WKWebsiteDataStore.nonPersistent() |
| 1752 | 1779 | static let heightCache = NSCache<NSString, NSNumber>() |
| 1753 | 1780 | |
| 1754 | let parent: HTMLWebViewRepresentable | |
| 1781 | var parent: HTMLWebViewRepresentable | |
| 1755 | 1782 | var lastHTML: String? |
| 1756 | 1783 | var lastReloadToken = 0 |
| 1757 | 1784 | |
| @@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc | ||
| 1785 | 1812 | } |
| 1786 | 1813 | |
| 1787 | 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 | 1823 | if isAllowedReadmeNavigationURL(requestURL) { |
| 1789 | 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 | } |