krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.12.1: Hutch/Networking/ManPageService.swift · raw
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}