(["p", "section", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "pre"])
39 | var withinPre = 0
40 | parsed?.body?.traverseChildren(onEnterElement: { el in
41 | if let tag = el.tag?.lowercased() {
42 | if tag == "pre" {
43 | withinPre += 1
44 | }
45 | if blockLevelTags.contains(tag) {
46 | paragraphs.append("")
47 | }
48 | }
49 | },
50 | onExitElement: { el in
51 | if el.tag?.lowercased() == "pre" {
52 | withinPre -= 1
53 | }
54 | },
55 | onText: { str in
56 | if withinPre > 0 {
57 | paragraphs[paragraphs.count - 1] += str
58 | } else {
59 | paragraphs[paragraphs.count - 1] += str.trimmingCharacters(in: .whitespacesAndNewlines)
60 | }
61 | })
62 | return paragraphs.filter({ $0 != "" }).joined(separator: "\n")
63 | // return parsed?.root?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
64 | }
65 | return ""
66 | }
67 | }
68 |
69 | extension Fuzi.XMLElement {
70 | func traverseChildren(onEnterElement: (Fuzi.XMLElement) -> Void, onExitElement: (Fuzi.XMLElement) -> Void, onText: (String) -> Void) {
71 | for node in childNodes(ofTypes: [.Element, .Text]) {
72 | switch node.type {
73 | case .Text:
74 | onText(node.stringValue)
75 | case .Element:
76 | if let el = node as? Fuzi.XMLElement {
77 | onEnterElement(el)
78 | el.traverseChildren(onEnterElement: onEnterElement, onExitElement: onExitElement, onText: onText)
79 | onExitElement(el)
80 | }
81 | default: ()
82 | }
83 | }
84 | }
85 | }
86 |
87 | public enum Extractor: Equatable {
88 | case mercury
89 | case readability
90 | }
91 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Extraction/MercuryExtractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import WebKit
3 |
4 | class MercuryExtractor: NSObject, WKUIDelegate, WKNavigationDelegate {
5 | static let shared = MercuryExtractor()
6 |
7 | let webview = WKWebView()
8 |
9 | override init() {
10 | super.init()
11 | webview.uiDelegate = self
12 | webview.navigationDelegate = self
13 | }
14 |
15 | private func initializeJS() {
16 | guard readyState == .none else { return }
17 | Reeeed.logger.info("Initializing...")
18 | readyState = .initializing
19 | let mercuryJS = try! String(contentsOf: Bundle.module.url(forResource: "mercury.web", withExtension: "js")!)
20 | let html = """
21 |
22 |
23 |
24 |
25 | """
26 | webview.loadHTMLString(html, baseURL: nil)
27 | }
28 |
29 | func warmUp() {
30 | // do nothing -- the real warmup is done in init
31 | initializeJS()
32 | }
33 |
34 | typealias ReadyBlock = () -> Void
35 | private var pendingReadyBlocks = [ReadyBlock]()
36 |
37 | private enum ReadyState {
38 | case none
39 | case initializing
40 | case ready
41 | }
42 |
43 | private var readyState = ReadyState.none {
44 | didSet {
45 | if readyState == .ready {
46 | for block in pendingReadyBlocks {
47 | block()
48 | }
49 | pendingReadyBlocks.removeAll()
50 | }
51 | }
52 | }
53 |
54 | private func waitUntilReady(_ callback: @escaping ReadyBlock) {
55 | switch readyState {
56 | case .ready: callback()
57 | case .none:
58 | pendingReadyBlocks.append(callback)
59 | initializeJS()
60 | case .initializing:
61 | pendingReadyBlocks.append(callback)
62 | }
63 | }
64 |
65 | typealias Callback = (ExtractedContent?) -> Void
66 |
67 | // TODO: queue up simultaneous requests?
68 | func extract(html: String, url: URL, callback: @escaping Callback) {
69 | waitUntilReady {
70 | let script = "return await Mercury.parse(\(url.absoluteString.asJSString), {html: \(html.asJSString)})"
71 |
72 | self.webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
73 | switch result {
74 | case .failure(let err):
75 | Reeeed.logger.error("Failed to extract: \(err)")
76 | callback(nil)
77 | case .success(let resultOpt):
78 | Reeeed.logger.info("Successfully extracted")
79 | let content = self.parse(dict: resultOpt as? [String: Any])
80 | if let content, content.extractPlainText.count >= 200 {
81 | callback(content)
82 | } else {
83 | callback(nil)
84 | }
85 | }
86 | }
87 | }
88 | }
89 |
90 | func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async {
91 | if message == "ok" {
92 | DispatchQueue.main.async {
93 | self.readyState = .ready
94 | Reeeed.logger.info("Ready")
95 | }
96 | }
97 | }
98 |
99 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
100 | Reeeed.logger.info("Web process did terminate")
101 | self.readyState = .none
102 | }
103 |
104 | private func parse(dict: [String: Any]?) -> ExtractedContent? {
105 | guard let result = dict else { return nil }
106 | let content = ExtractedContent(
107 | content: result["content"] as? String,
108 | author: result["author"] as? String,
109 | title: result["title"] as? String,
110 | excerpt: result["excerpt"] as? String
111 | )
112 | return content
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Extraction/ReadabilityExtractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import WebKit
3 |
4 | class ReadabilityExtractor: NSObject, WKUIDelegate, WKNavigationDelegate {
5 | static let shared = ReadabilityExtractor()
6 |
7 | let webview = WKWebView()
8 |
9 | override init() {
10 | super.init()
11 | webview.uiDelegate = self
12 | webview.navigationDelegate = self
13 | }
14 |
15 | private func initializeJS() {
16 | guard readyState == .none else { return }
17 | Reeeed.logger.info("Initializing...")
18 | readyState = .initializing
19 | let js = try! String(contentsOf: Bundle.module.url(forResource: "readability.bundle.min", withExtension: "js")!)
20 | let html = """
21 |
22 |
23 |
24 |
25 | """
26 | webview.loadHTMLString(html, baseURL: nil)
27 | }
28 |
29 | func warmUp() {
30 | // do nothing -- the real warmup is done in init
31 | initializeJS()
32 | }
33 |
34 | typealias ReadyBlock = () -> Void
35 | private var pendingReadyBlocks = [ReadyBlock]()
36 |
37 | private enum ReadyState {
38 | case none
39 | case initializing
40 | case ready
41 | }
42 |
43 | private var readyState = ReadyState.none {
44 | didSet {
45 | if readyState == .ready {
46 | for block in pendingReadyBlocks {
47 | block()
48 | }
49 | pendingReadyBlocks.removeAll()
50 | }
51 | }
52 | }
53 |
54 | private func waitUntilReady(_ callback: @escaping ReadyBlock) {
55 | switch readyState {
56 | case .ready: callback()
57 | case .none:
58 | pendingReadyBlocks.append(callback)
59 | initializeJS()
60 | case .initializing:
61 | pendingReadyBlocks.append(callback)
62 | }
63 | }
64 |
65 | typealias Callback = (ExtractedContent?) -> Void
66 |
67 | func extract(html: String, url: URL, callback: @escaping Callback) {
68 | waitUntilReady {
69 | let script = "return await parse(\(html.asJSString), \(url.absoluteString.asJSString))"
70 |
71 | self.webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
72 | switch result {
73 | case .failure(let err):
74 | Reeeed.logger.error("Failed to extract: \(err)")
75 | callback(nil)
76 | case .success(let resultOpt):
77 | Reeeed.logger.info("Successfully extracted: \(resultOpt)")
78 | let content = self.parse(dict: resultOpt as? [String: Any])
79 | callback(content)
80 | }
81 | }
82 | }
83 | }
84 |
85 | func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async {
86 | if message == "ok" {
87 | DispatchQueue.main.async {
88 | self.readyState = .ready
89 | Reeeed.logger.info("Ready")
90 | }
91 | }
92 | }
93 |
94 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
95 | Reeeed.logger.info("Web process did terminate")
96 | self.readyState = .none
97 | }
98 |
99 | private func parse(dict: [String: Any]?) -> ExtractedContent? {
100 | guard let result = dict else { return nil }
101 | let content = ExtractedContent(
102 | content: result["content"] as? String,
103 | author: result["author"] as? String,
104 | title: result["title"] as? String,
105 | excerpt: result["excerpt"] as? String
106 | )
107 | return content
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Extraction/SiteMetadataExtraction.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftSoup
3 | import Fuzi
4 |
5 | public struct SiteMetadata: Equatable, Codable {
6 | public var url: URL
7 | public var title: String?
8 | public var description: String?
9 | public var heroImage: URL?
10 | public var favicon: URL?
11 |
12 | private struct MetadataParseError: Error {}
13 |
14 | public init(url: URL, title: String? = nil, description: String? = nil, heroImage: URL? = nil, favicon: URL? = nil) {
15 | self.url = url
16 | self.title = title
17 | self.description = description
18 | self.heroImage = heroImage
19 | self.favicon = favicon
20 | }
21 |
22 | public static func extractMetadata(fromHTML html: String, baseURL: URL) async throws -> SiteMetadata {
23 | try await withCheckedThrowingContinuation { continuation in
24 | DispatchQueue.metadataExtractorQueue.async {
25 | do {
26 | let doc = try HTMLDocument(stringSAFE: html)
27 | var md = SiteMetadata(url: baseURL)
28 | md.title = (doc.ogTitle ?? doc.title)?.trimmingCharacters(in: .whitespacesAndNewlines)
29 | md.heroImage = doc.ogImage(baseURL: baseURL)
30 | md.description = doc.metaDescription?.nilIfEmpty
31 | md.favicon = doc.favicon(baseURL: baseURL) ?? baseURL.inferredFaviconURL
32 | continuation.resume(returning: md)
33 | } catch {
34 | continuation.resume(throwing: error)
35 | }
36 | }
37 | }
38 | }
39 | }
40 |
41 | private extension DispatchQueue {
42 | static let metadataExtractorQueue = DispatchQueue(label: "MetadataExtractor", qos: .default, attributes: .concurrent)
43 | }
44 |
45 | private extension Fuzi.HTMLDocument {
46 | private func getAttribute(selector: String, attribute: String) -> String? {
47 | return css(selector).first?.attr(attribute, namespace: nil)
48 | }
49 |
50 | var metaDescription: String? { getAttribute(selector: "meta[name='description']", attribute: "content") }
51 |
52 | var ogTitle: String? { getAttribute(selector: "meta[property='og:title']", attribute: "content") }
53 |
54 | func ogImage(baseURL: URL) -> URL? {
55 | if let link = getAttribute(selector: "meta[property='og:image']", attribute: "content") {
56 | return URL(string: link, relativeTo: baseURL)
57 | }
58 | return nil
59 | }
60 |
61 | func favicon(baseURL: URL) -> URL? {
62 | for item in css("link") {
63 | if let rel = item.attr("rel", namespace: nil),
64 | (rel == "icon" || rel == "shortcut icon"),
65 | let val = item.attr("href", namespace: nil),
66 | let resolved = URL(string: val, relativeTo: baseURL) {
67 | return resolved
68 | }
69 | }
70 | return nil
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/Reeeed/JS/DO NOT OPEN THESE FILES IN XCODE.txt:
--------------------------------------------------------------------------------
1 | it might crash!
2 |
--------------------------------------------------------------------------------
/Sources/Reeeed/ReadableDoc.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct ReadableDoc: Equatable, Codable {
4 | public var extracted: ExtractedContent
5 | public var html: String
6 | public var insertHeroImage: Bool
7 | public var metadata: SiteMetadata
8 | public var date: Date?
9 |
10 | public init?(extracted: ExtractedContent, insertHeroImage: Bool? /* autodetect if nil */, metadata: SiteMetadata, date: Date? = nil) {
11 | guard let html = extracted.content else {
12 | return nil
13 | }
14 | self.html = html
15 | self.extracted = extracted
16 | if let insertHeroImage {
17 | self.insertHeroImage = insertHeroImage
18 | } else if let html = extracted.content {
19 | self.insertHeroImage = (try? estimateLinesUntilFirstImage(html: html) ?? 999 >= 10) ?? false
20 | } else {
21 | self.insertHeroImage = false
22 | }
23 | self.metadata = metadata
24 | self.date = date ?? extracted.datePublished
25 | }
26 |
27 | public var title: String? {
28 | extracted.title ?? metadata.title
29 | }
30 |
31 | public var url: URL {
32 | metadata.url
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Reeeed.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol Logger {
4 | func info(_ string: String)
5 | func error(_ string: String)
6 | }
7 |
8 | struct PrintLogger: Logger {
9 | func info(_ string: String) {
10 | print("[Reeeed] ℹ️ \(string)")
11 | }
12 | func error(_ string: String) {
13 | print("[Reeeed] 🚨 \(string)")
14 | }
15 | }
16 |
17 | public enum Reeeed {
18 | public static var logger: Logger = PrintLogger()
19 |
20 | public static func warmup(extractor: Extractor = .mercury) {
21 | switch extractor {
22 | case .mercury:
23 | MercuryExtractor.shared.warmUp()
24 | case .readability:
25 | ReadabilityExtractor.shared.warmUp()
26 | }
27 | }
28 |
29 | public static func extractArticleContent(url: URL, html: String, extractor: Extractor = .mercury) async throws -> ExtractedContent {
30 | return try await withCheckedThrowingContinuation({ continuation in
31 | DispatchQueue.main.async {
32 | switch extractor {
33 | case .mercury:
34 | MercuryExtractor.shared.extract(html: html, url: url) { contentOpt in
35 | if let content = contentOpt {
36 | continuation.resume(returning: content)
37 | } else {
38 | continuation.resume(throwing: ExtractionError.FailedToExtract)
39 | }
40 | }
41 | case .readability:
42 | ReadabilityExtractor.shared.extract(html: html, url: url) { contentOpt in
43 | if let content = contentOpt {
44 | continuation.resume(returning: content)
45 | } else {
46 | continuation.resume(throwing: ExtractionError.FailedToExtract)
47 | }
48 | }
49 | }
50 | }
51 | })
52 | }
53 |
54 | public struct FetchAndExtractionResult {
55 | public var metadata: SiteMetadata?
56 | public var extracted: ExtractedContent
57 | public var styledHTML: String
58 | public var baseURL: URL
59 |
60 | public var title: String? {
61 | extracted.title?.nilIfEmpty ?? metadata?.title?.nilIfEmpty
62 | }
63 | }
64 |
65 | public static func fetchAndExtractContent(fromURL url: URL, extractor: Extractor = .mercury) async throws -> ReadableDoc {
66 | DispatchQueue.main.async { Reeeed.warmup() }
67 |
68 | let (data, response) = try await URLSession.shared.data(from: url)
69 | guard let html = String(data: data, encoding: .utf8) else {
70 | throw ExtractionError.DataIsNotString
71 | }
72 | let baseURL = response.url ?? url
73 | let content = try await Reeeed.extractArticleContent(url: baseURL, html: html)
74 | let extractedMetadata = try? await SiteMetadata.extractMetadata(fromHTML: html, baseURL: baseURL)
75 | guard let doc = ReadableDoc(
76 | extracted: content,
77 | insertHeroImage: nil,
78 | metadata: extractedMetadata ?? SiteMetadata(url: url),
79 | date: content.datePublished)
80 | else {
81 | throw ExtractionError.MissingExtractionData
82 | }
83 | return doc
84 |
85 | // let styledHTML = Reeeed.wrapHTMLInReaderStyling(html: extractedHTML, title: content.title ?? extractedMetadata?.title ?? "", baseURL: baseURL, author: content.author, heroImage: extractedMetadata?.heroImage, includeExitReaderButton: true, theme: theme, date: content.datePublished)
86 | // return .init(metadata: extractedMetadata, extracted: content, styledHTML: styledHTML, baseURL: baseURL)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Reeeed/UI/ReadableDoc+HTML.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftSoup
3 | import Fuzi
4 |
5 |
6 | extension ReadableDoc {
7 | public func html(includeExitReaderButton: Bool, theme: ReaderTheme = .init()) -> String {
8 | let escapedTitle = Entities.escape(title?.byStrippingSiteNameFromPageTitle ?? "")
9 |
10 | var heroHTML: String = ""
11 | if insertHeroImage, let hero = metadata.heroImage {
12 | let safeURL = Entities.escape(hero.absoluteString)
13 | heroHTML = "
"
14 | }
15 |
16 | let subtitle: String = {
17 | var partsHTML = [String]()
18 |
19 | let separatorHTML = " · "
20 | func appendSeparatorIfNecessary() {
21 | if partsHTML.count > 0 {
22 | partsHTML.append(separatorHTML)
23 | }
24 | }
25 | if let author = extracted.author {
26 | partsHTML.append(Entities.escape(author))
27 | }
28 | if let date {
29 | appendSeparatorIfNecessary()
30 | partsHTML.append(DateFormatter.shortDateOnly.string(from: date))
31 | }
32 |
33 | appendSeparatorIfNecessary()
34 | partsHTML.append(metadata.url.hostWithoutWWW)
35 |
36 | // if partsHTML.count == 0 { return "" }
37 | return "\(partsHTML.joined())
"
38 | }()
39 |
40 | let exitReaderButton: String
41 | if includeExitReaderButton {
42 | exitReaderButton = ""
43 | } else {
44 | exitReaderButton = ""
45 | }
46 |
47 | let wrapped = """
48 |
49 |
50 |
51 | \(escapedTitle)
52 |
55 |
56 |
57 | \(heroHTML)
58 |
59 |
\(escapedTitle)
60 | \(subtitle)
61 | \(extracted.content ?? "")
62 |
66 |
67 |
68 |
73 |
74 |
75 | """
76 | return wrapped
77 | }
78 | }
79 |
80 | extension ReaderTheme {
81 | public var css: String {
82 | let (fgLight, fgDark) = foreground.hexPair
83 | let (fg2Light, fg2Dark) = foreground2.hexPair
84 | let (bgLight, bgDark) = background.hexPair
85 | let (bg2Light, bg2Dark) = background2.hexPair
86 | let (linkLight, linkDark) = link.hexPair
87 |
88 | return """
89 | html, body {
90 | margin: 0;
91 | }
92 |
93 | body {
94 | color: \(fgLight);
95 | background-color: \(bgLight);
96 | overflow-wrap: break-word;
97 | font: -apple-system-body;
98 | }
99 |
100 | .__hero {
101 | display: block;
102 | width: 100%;
103 | height: 50vw;
104 | max-height: 300px;
105 | object-fit: cover;
106 | overflow: hidden;
107 | border-radius: 7px;
108 | }
109 |
110 | #__content {
111 | line-height: 1.5;
112 | font-size: 1.1em;
113 | overflow-x: hidden;
114 | }
115 |
116 | @media screen and (min-width: 650px) {
117 | #__content { font-size: 1.35em; line-height: 1.5; }
118 | }
119 |
120 | h1, h2, h3, h4, h5, h6 {
121 | line-height: 1.2;
122 | font-family: -apple-system;
123 | font-size: 1.5em;
124 | font-weight: 800;
125 | }
126 |
127 | #__title {
128 | font-size: 1.8em;
129 | }
130 |
131 | img, iframe, object, video {
132 | max-width: 100%;
133 | height: auto;
134 | border-radius: 7px;
135 | }
136 |
137 | pre {
138 | max-width: 100%;
139 | overflow-x: auto;
140 | }
141 |
142 | table {
143 | display: block;
144 | max-width: 100%;
145 | overflow-x: auto;
146 | }
147 |
148 | a:link {
149 | color: \(linkLight);
150 | }
151 |
152 | figure {
153 | margin-left: 0;
154 | margin-right: 0;
155 | }
156 |
157 | figcaption, cite {
158 | opacity: 0.5;
159 | font-size: small;
160 | }
161 |
162 | @media screen and (max-width: 500px) {
163 | dd {
164 | margin-inline-start: 20px; /* normally 40px */
165 | }
166 | blockquote {
167 | margin-inline-start: 20px; /* normally 40px */
168 | margin-inline-end: 20px; /* normally 40px */
169 | }
170 | }
171 |
172 | .__subtitle {
173 | font-weight: bold;
174 | vertical-align: baseline;
175 | opacity: 0.5;
176 | font-size: 0.9em;
177 | }
178 |
179 | .__subtitle .__icon {
180 | width: 1.2em;
181 | height: 1.2em;
182 | object-fit: cover;
183 | overflow: hidden;
184 | border-radius: 3px;
185 | margin-right: 0.3em;
186 | position: relative;
187 | top: 0.3em;
188 | }
189 |
190 | .__subtitle .__separator {
191 | opacity: 0.5;
192 | }
193 |
194 | #__content {
195 | padding: 1.5em;
196 | margin: auto;
197 | margin-top: 5px;
198 | max-width: 700px;
199 | }
200 |
201 | @media (prefers-color-scheme: dark) {
202 | body {
203 | color: \(fgDark);
204 | background-color: \(bgDark);
205 | }
206 | a:link { color: \(linkDark); }
207 | }
208 |
209 | #__footer {
210 | margin-bottom: 4em;
211 | margin-top: 2em;
212 | }
213 |
214 | #__footer > .label {
215 | font-size: small;
216 | opacity: 0.5;
217 | text-align: center;
218 | margin-bottom: 0.66em;
219 | font-weight: 500;
220 | }
221 |
222 | #__footer > button {
223 | padding: 0.5em;
224 | text-align: center;
225 | background-color: \(bg2Light);
226 | font-weight: 500;
227 | color: \(fg2Light);
228 | min-height: 44px;
229 | display: flex;
230 | align-items: center;
231 | justify-content: center;
232 | width: 100%;
233 | font-size: 1em;
234 | border: none;
235 | border-radius: 0.5em;
236 | }
237 |
238 | @media (prefers-color-scheme: dark) {
239 | #__footer > button {
240 | background-color: \(bg2Dark);
241 | color: \(fg2Dark);
242 | }
243 | }
244 |
245 | \(additionalCSS ?? "")
246 | """
247 | }
248 | }
249 |
250 | public extension URL {
251 | /// If HTML is generated with `includeExitReaderButton=true`, clicking the button will navigate to this URL, which you should intercept and use to display the original website.
252 | static let exitReaderModeLink = URL(string: "feeeed://exit-reader-mode")!
253 | }
254 |
255 | extension URL {
256 | var googleFaviconURL: URL? {
257 | if let host {
258 | return URL(string: "https://www.google.com/s2/favicons?domain=\(host)&sz=64")
259 | }
260 | return nil
261 | }
262 | }
263 |
264 | func estimateLinesUntilFirstImage(html: String) throws -> Int? {
265 | let doc = try HTMLDocument(data: html.data(using: .utf8)!)
266 | var lines = 0
267 | var linesBeforeFirst: Int?
268 | try doc.root?.traverse { el in
269 | if el.tag?.lowercased() == "img", linesBeforeFirst == nil {
270 | linesBeforeFirst = lines
271 | }
272 | lines += el.estLineCount
273 | }
274 | return linesBeforeFirst
275 | }
276 |
277 | extension Fuzi.XMLElement {
278 | func traverse(_ block: (Fuzi.XMLElement) -> Void) throws {
279 | for child in children {
280 | block(child)
281 | try child.traverse(block)
282 | }
283 | }
284 | var estLineCount: Int {
285 | if let tag = self.tag?.lowercased() {
286 | switch tag {
287 | case "video", "embed": return 5
288 | case "h1", "h2", "h3", "h4", "h5", "h6", "p", "li":
289 | return Int(ceil(Double(stringValue.count) / 60)) + 1
290 | case "tr": return 1
291 | default: return 0
292 | }
293 | }
294 | return 0
295 | }
296 | }
297 |
298 | extension DateFormatter {
299 | static let shortDateOnly: DateFormatter = {
300 | let formatter = DateFormatter()
301 | formatter.dateStyle = .short
302 | formatter.timeStyle = .none
303 | return formatter
304 | }()
305 | }
306 |
307 | //extension SwiftSoup.Node {
308 | // func traverseElements(_ block: @escaping (Element) -> Void) throws {
309 | // let visitor = BlockNodeVisitor(headCallback: { (node, _depth) in
310 | // if let el = node as? Element {
311 | // block(el)
312 | // }
313 | // }, tailCallback: nil)
314 | // try traverse(visitor)
315 | // }
316 | //}
317 | //
318 | //private struct BlockNodeVisitor: NodeVisitor {
319 | // var headCallback: ((Node, Int) -> Void)?
320 | // var tailCallback: ((Node, Int) -> Void)?
321 | //
322 | // func head(_ node: Node, _ depth: Int) throws {
323 | // headCallback?(node, depth)
324 | // }
325 | //
326 | // func tail(_ node: Node, _ depth: Int) throws {
327 | // tailCallback?(node, depth)
328 | // }
329 | //}
330 |
--------------------------------------------------------------------------------
/Sources/Reeeed/UI/ReaderPlaceholder.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Foundation
3 |
4 | public struct ReaderPlaceholder: View {
5 | var theme: ReaderTheme
6 |
7 | public init(theme: ReaderTheme = .init()) {
8 | self.theme = theme
9 | }
10 |
11 | public var body: some View {
12 | GeometryReader { geo in
13 | VStack(alignment: .leading, spacing: baseFontSize) {
14 | Color(theme.foreground2)
15 | .cornerRadius(7)
16 | .opacity(0.3)
17 | .padding(.top, 5)
18 |
19 | Text("Lorem Ipsum Dolor Sit Amet")
20 | .font(.system(size: baseFontSize * 1.5).bold())
21 |
22 | Text("Article Author")
23 | .opacity(0.5)
24 | .font(.system(size: baseFontSize * 0.833))
25 |
26 | Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at tortor justo. Donec nec sapien at nunc ullamcorper mattis vel at enim. Ut sollicitudin sed dui a consectetur. Pellentesque eu convallis quam, id accumsan felis. Nunc ornare condimentum lectus, non tristique massa sodales eu. Vivamus tincidunt eget ex et dignissim. In consectetur turpis sit amet pretium volutpat.")
27 |
28 | Text("Nulla rhoncus nibh vitae arcu pellentesque congue. Nullam tempor cursus sem eget vehicula. Nulla sit amet enim eu eros finibus suscipit faucibus vel orci. Pellentesque id mollis lorem, id euismod est. Nullam in sapien purus. Nulla sed tellus augue. Mauris aliquet suscipit lectus.")
29 | }
30 | .font(.system(size: baseFontSize))
31 | .multilineTextAlignment(.leading)
32 | .lineSpacing(baseFontSize * 0.5)
33 | .frame(maxWidth: 700)
34 | .frame(maxWidth: .infinity)
35 | .redacted(reason: .placeholder)
36 | .opacity(0.3)
37 | }
38 | .modifier(ShimmerMask())
39 | .padding(baseFontSize * 1.5)
40 | .background(Color(theme.background).edgesIgnoringSafeArea(.all))
41 | }
42 |
43 | private var baseFontSize: CGFloat { 19 }
44 | }
45 |
46 | private struct ShimmerMask: ViewModifier {
47 | var delay: TimeInterval = 1
48 | private let animation = Animation.easeInOut(duration: 1).repeatForever(autoreverses: false)
49 |
50 | @State private var endState = false
51 |
52 | func body(content: Content) -> some View {
53 | content
54 | .mask {
55 | LinearGradient(colors: [Color.black, Color.black.opacity(0), Color.black], startPoint: startPoint, endPoint: endPoint)
56 | }
57 | .onAppear {
58 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
59 | withAnimation(animation) {
60 | endState.toggle()
61 | }
62 | }
63 | }
64 | }
65 |
66 | private var startPoint: UnitPoint {
67 | .init(x: endState ? 1 : -1, y: 0)
68 | }
69 |
70 | private var endPoint: UnitPoint {
71 | .init(x: startPoint.x + 1, y: 0)
72 | }
73 | }
74 |
75 | //struct ReaderPlaceholder_Previews: PreviewProvider {
76 | // static var previews: some View {
77 | // ReaderPlaceholder()
78 | // .background(Color("Background2"))
79 | // .frame(width: 375)
80 | // }
81 | //}
82 | //
83 |
--------------------------------------------------------------------------------
/Sources/Reeeed/UI/ReaderTheme.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | #if os(macOS)
4 | import AppKit
5 | public typealias UINSColor = NSColor
6 | #else
7 | import UIKit
8 | public typealias UINSColor = UIColor
9 | #endif
10 |
11 | public struct ReaderTheme {
12 | public var foreground: UINSColor // for body text
13 | public var foreground2: UINSColor // used for button titles
14 | public var background: UINSColor // page background
15 | public var background2: UINSColor // used for buttons
16 | public var link: UINSColor
17 | public var additionalCSS: String?
18 |
19 | public init(
20 | foreground: UINSColor = .reader_Primary,
21 | foreground2: UINSColor = .reader_Secondary,
22 | background: UINSColor = .reader_Background,
23 | background2: UINSColor = .reader_Background2,
24 | link: UINSColor = .systemBlue,
25 | additionalCSS: String? = nil
26 | ) {
27 | self.foreground = foreground
28 | self.foreground2 = foreground2
29 | self.background = background
30 | self.background2 = background2
31 | self.link = link
32 | self.additionalCSS = additionalCSS
33 | }
34 | }
35 |
36 | public extension UINSColor {
37 | #if os(macOS)
38 | static let reader_Primary = NSColor.labelColor
39 | static let reader_Secondary = NSColor.secondaryLabelColor
40 | static let reader_Background = NSColor.textBackgroundColor
41 | static let reader_Background2 = NSColor.windowBackgroundColor
42 | #else
43 | static let reader_Primary = UIColor.label
44 | static let reader_Secondary = UIColor.secondaryLabel
45 | static let reader_Background = UIColor.systemBackground
46 | static let reader_Background2 = UIColor.secondarySystemBackground
47 | #endif
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/Reeeed/UI/ReeeederView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct ReeeederViewOptions {
4 | public var theme: ReaderTheme
5 | public var onLinkClicked: ((URL) -> Void)?
6 | public init(theme: ReaderTheme = .init(), onLinkClicked: ((URL) -> Void)? = nil) {
7 | self.theme = theme
8 | self.onLinkClicked = onLinkClicked
9 | }
10 | }
11 |
12 | public struct ReeeederView: View {
13 | var url: URL
14 | var options: ReeeederViewOptions
15 |
16 | public init(url: URL, options: ReeeederViewOptions = .init()) {
17 | self.url = url
18 | self.options = options
19 | }
20 |
21 | // MARK: - Implementation
22 | enum Status: Equatable {
23 | case fetching
24 | case failedToExtractContent
25 | case extractedContent(html: String, baseURL: URL, title: String?)
26 | }
27 | @State private var status = Status.fetching
28 | @State private var titleFromFallbackWebView: String?
29 |
30 | public var body: some View {
31 | Color(options.theme.background)
32 | .overlay(content)
33 | .edgesIgnoringSafeArea(.all)
34 | .overlay(loader)
35 | .navigationTitle(title ?? url.hostWithoutWWW)
36 | #if os(iOS)
37 | .navigationBarTitleDisplayMode(.inline)
38 | #endif
39 | .task {
40 | do {
41 | let result = try await Reeeed.fetchAndExtractContent(fromURL: url)
42 | let html = result.html(includeExitReaderButton: true, theme: options.theme)
43 | self.status = .extractedContent(html: html, baseURL: result.url, title: result.title)
44 | } catch {
45 | status = .failedToExtractContent
46 | }
47 | }
48 | // TODO: Respond to dynamic theme changes
49 | }
50 |
51 | @ViewBuilder private var content: some View {
52 | switch status {
53 | case .fetching:
54 | EmptyView()
55 | case .failedToExtractContent:
56 | FallbackWebView(url: url, onLinkClicked: onLinkClicked, title: $titleFromFallbackWebView)
57 | case .extractedContent(let html, let baseURL, _):
58 | ReaderWebView(baseURL: baseURL, html: html, onLinkClicked: onLinkClicked)
59 | }
60 | }
61 |
62 | // TODO: Show loader while fallback page is loading
63 | @ViewBuilder private var loader: some View {
64 | ReaderPlaceholder(theme: options.theme)
65 | .opacity(showLoader ? 1 : 0)
66 | .animation(.default, value: showLoader)
67 | }
68 |
69 | private var showLoader: Bool {
70 | status == .fetching
71 | }
72 |
73 | private var title: String? {
74 | switch status {
75 | case .fetching:
76 | return nil
77 | case .failedToExtractContent:
78 | return titleFromFallbackWebView
79 | case .extractedContent(_, _, let title):
80 | return title
81 | }
82 | }
83 |
84 | private func onLinkClicked(_ url: URL) {
85 | if url == .exitReaderModeLink {
86 | showNormalPage()
87 | } else {
88 | options.onLinkClicked?(url)
89 | }
90 | }
91 |
92 | private func showNormalPage() {
93 | status = .failedToExtractContent // TODO: Model this state correctly
94 | }
95 | }
96 |
97 | private struct FallbackWebView: View {
98 | var url: URL
99 | var onLinkClicked: ((URL) -> Void)?
100 | @Binding var title: String?
101 |
102 | @StateObject private var content = WebContent()
103 |
104 | var body: some View {
105 | WebView(content: content)
106 | .onAppear {
107 | setupLinkHandler()
108 | }
109 | .onAppearOrChange(url) { url in
110 | content.populate { content in
111 | content.load(url: url)
112 | }
113 | }
114 | .onChange(of: content.info.title) { self.title = $0 }
115 | }
116 |
117 | private func setupLinkHandler() {
118 | content.shouldBlockNavigation = { action -> Bool in
119 | if action.navigationType == .linkActivated, let url = action.request.url {
120 | onLinkClicked?(url)
121 | return true
122 | }
123 | return false
124 | }
125 | }
126 | }
127 |
128 | private struct ReaderWebView: View {
129 | var baseURL: URL
130 | var html: String
131 | var onLinkClicked: ((URL) -> Void)?
132 | // TODO: Handle "wants to exit reader"
133 |
134 | @StateObject private var content = WebContent(transparent: true)
135 |
136 | var body: some View {
137 | WebView(content: content)
138 | .onAppear {
139 | setupLinkHandler()
140 | }
141 | .onAppearOrChange(Model(baseURL: baseURL, html: html)) { model in
142 | content.populate { content in
143 | content.load(html: model.html, baseURL: model.baseURL)
144 | }
145 | }
146 | }
147 |
148 | private struct Model: Equatable {
149 | var baseURL: URL
150 | var html: String
151 | }
152 |
153 | private func setupLinkHandler() {
154 | content.shouldBlockNavigation = { action -> Bool in
155 | if let url = action.request.url,
156 | url == .exitReaderModeLink || action.navigationType == .linkActivated {
157 | onLinkClicked?(url)
158 | return true
159 | }
160 | return false
161 | }
162 | }
163 | }
164 |
165 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Utils/ColorExtraction.swift:
--------------------------------------------------------------------------------
1 | // From https://stackoverflow.com/questions/56586055/how-to-get-rgb-components-from-color-in-swiftui
2 |
3 | import SwiftUI
4 |
5 | #if os(macOS)
6 | import AppKit
7 | //typealias UINSColor = NSColor
8 | #else
9 | import UIKit
10 | //typealias UINSColor = UIColor
11 | #endif
12 |
13 | extension UINSColor {
14 | var components: (red: CGFloat, green: CGFloat, blue: CGFloat, opacity: CGFloat) {
15 | var r: CGFloat = 0
16 | var g: CGFloat = 0
17 | var b: CGFloat = 0
18 | var o: CGFloat = 0
19 | #if os(macOS)
20 | usingColorSpace(.deviceRGB)!.getRed(&r, green: &g, blue: &b, alpha: &o)
21 | #else
22 | guard getRed(&r, green: &g, blue: &b, alpha: &o) else {
23 | // You can handle the failure here as you want
24 | return (0, 0, 0, 0)
25 | }
26 | #endif
27 | return (r, g, b, o)
28 | }
29 |
30 | // From https://stackoverflow.com/questions/26341008/how-to-convert-uicolor-to-hex-and-display-in-nslog
31 | var hexString: String {
32 | let (red, green, blue, _) = components
33 | let hexString = String.init(format: "#%02lX%02lX%02lX", lroundf(Float(red * 255)), lroundf(Float(green * 255)), lroundf(Float(blue * 255)))
34 | return hexString
35 | }
36 |
37 | var hexPair: (light: String, dark: String) {
38 | var light: String!
39 | var dark: String!
40 | withColorScheme(dark: false) {
41 | light = self.hexString
42 | }
43 | withColorScheme(dark: true) {
44 | dark = self.hexString
45 | }
46 | return (light, dark)
47 | }
48 | }
49 |
50 | private func withColorScheme(dark: Bool /* otherwise light */, block: () -> Void) {
51 | #if os(macOS)
52 | NSAppearance(named: dark ? .darkAqua : .aqua)!.performAsCurrentDrawingAppearance {
53 | block()
54 | }
55 | #else
56 | UITraitCollection(userInterfaceStyle: dark ? .dark : .light).performAsCurrent {
57 | block()
58 | }
59 | #endif
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Utils/Utils.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 | import Fuzi
4 |
5 | extension HTMLDocument {
6 | // Work around iOS 18 crash when doing HTMLDocument(string: ...) directly
7 | // Seems to be fine if you convert the string to data first
8 | public convenience init(stringSAFE: String) throws {
9 | try self.init(data: Data(stringSAFE.utf8))
10 | }
11 | }
12 |
13 |
14 | extension String {
15 | var asJSString: String {
16 | let data = try! JSONSerialization.data(withJSONObject: self, options: .fragmentsAllowed)
17 | return String(data: data, encoding: .utf8)!
18 | }
19 |
20 | var byStrippingSiteNameFromPageTitle: String {
21 | for separator in [" | ", " – ", " — ", " - "] {
22 | if self.contains(separator), let firstComponent = components(separatedBy: separator).first, firstComponent != "" {
23 | return firstComponent.byStrippingSiteNameFromPageTitle
24 | }
25 | }
26 | return self
27 | }
28 |
29 | var nilIfEmpty: String? {
30 | return isEmpty ? nil : self
31 | }
32 | }
33 |
34 | extension URL {
35 | var inferredFaviconURL: URL {
36 | return URL(string: "/favicon.ico", relativeTo: self)!
37 | }
38 |
39 | var hostWithoutWWW: String {
40 | var parts = (host ?? "").components(separatedBy: ".")
41 | if parts.first == "www" {
42 | parts.remove(at: 0)
43 | }
44 | return parts.joined(separator: ".")
45 | }
46 | }
47 |
48 | extension View {
49 | func onAppearOrChange(_ value: T, perform: @escaping (T) -> Void) -> some View {
50 | self.onAppear(perform: { perform(value) }).onChange(of: value, perform: perform)
51 | }
52 | }
53 |
54 | func assertNotOnMainThread() {
55 | #if DEBUG
56 | assert(!Thread.isMainThread)
57 | #endif
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Utils/WebView/WebContent.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import WebKit
3 | import Combine
4 |
5 | class WebContent: NSObject, WKNavigationDelegate, WKUIDelegate, ObservableObject {
6 | fileprivate let webview: WKWebView
7 | private var observers = [NSKeyValueObservation]()
8 | private var subscriptions = Set()
9 |
10 | // MARK: - API
11 | struct Info: Equatable, Codable {
12 | var url: URL?
13 | var title: String?
14 | var canGoBack = false
15 | var canGoForward = false
16 | var isLoading = false
17 | }
18 |
19 | @Published private(set) var info = Info()
20 | var shouldBlockNavigation: ((WKNavigationAction) -> Bool)?
21 |
22 | func load(url: URL) {
23 | webview.load(.init(url: url))
24 | }
25 |
26 | func load(html: String, baseURL: URL?) {
27 | webview.loadHTMLString(html, baseURL: baseURL)
28 | }
29 |
30 | init(transparent: Bool = false, allowsInlinePlayback: Bool = false, autoplayAllowed: Bool = false) {
31 | let config = WKWebViewConfiguration()
32 | #if os(iOS)
33 | config.allowsInlineMediaPlayback = allowsInlinePlayback
34 | if autoplayAllowed {
35 | config.mediaTypesRequiringUserActionForPlayback = []
36 | }
37 | #endif
38 | webview = WKWebView(frame: .zero, configuration: config)
39 | webview.allowsBackForwardNavigationGestures = true
40 | self.transparent = transparent
41 | super.init()
42 | webview.navigationDelegate = self
43 | webview.uiDelegate = self
44 |
45 | observers.append(webview.observe(\.url, changeHandler: { [weak self] _, _ in
46 | self?.needsMetadataRefresh()
47 | }))
48 |
49 | observers.append(webview.observe(\.url, changeHandler: { [weak self] _, _ in
50 | self?.needsMetadataRefresh()
51 | }))
52 |
53 | observers.append(webview.observe(\.canGoBack, changeHandler: { [weak self] _, val in
54 | self?.info.canGoBack = val.newValue ?? false
55 | }))
56 |
57 | observers.append(webview.observe(\.canGoForward, changeHandler: { [weak self] _, val in
58 | self?.info.canGoForward = val.newValue ?? false
59 | }))
60 |
61 | observers.append(webview.observe(\.isLoading, changeHandler: { [weak self] _, val in
62 | self?.info.isLoading = val.newValue ?? false
63 | }))
64 |
65 | #if os(macOS)
66 | // no op
67 | #else
68 | webview.scrollView.backgroundColor = nil
69 | NotificationCenter.default.addObserver(self, selector: #selector(appDidForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
70 | #endif
71 | updateTransparency()
72 |
73 | }
74 |
75 | var transparent: Bool = false {
76 | didSet(old) {
77 | if transparent != old { updateTransparency() }
78 | }
79 | }
80 |
81 | private func updateTransparency() {
82 | #if os(macOS)
83 | // TODO: Implement transparency on macOS
84 | #else
85 | webview.backgroundColor = transparent ? nil : UINSColor.white
86 | webview.isOpaque = !transparent
87 | #endif
88 | }
89 |
90 | #if os(macOS)
91 | var view: NSView { webview }
92 | #else
93 | var view: UIView { webview }
94 | #endif
95 |
96 | func goBack() {
97 | webview.goBack()
98 | }
99 |
100 | func goForward() {
101 | webview.goForward()
102 | }
103 |
104 | func configure(_ block: (WKWebView) -> Void) {
105 | block(webview)
106 | }
107 |
108 | // MARK: - Populate
109 |
110 | private var populateBlock: ((WebContent) -> Void)?
111 | private var waitingForRepopulationAfterProcessTerminate = false
112 | /// A webview's content process can be terminated while the app is in the background.
113 | /// `populate` allows you to handle this.
114 | /// Wrap your calls to load content into the webview within `populate`.
115 | /// The code will be called immediately, but _also_ after process termination.
116 | func populate(_ block: @escaping (WebContent) -> Void) {
117 | waitingForRepopulationAfterProcessTerminate = false
118 | populateBlock = block
119 | block(self)
120 | }
121 |
122 | // MARK: - Lifecycle
123 | @objc private func appDidForeground() {
124 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
125 | if self.waitingForRepopulationAfterProcessTerminate, let block = self.populateBlock {
126 | block(self)
127 | }
128 | self.waitingForRepopulationAfterProcessTerminate = false
129 | }
130 | }
131 |
132 | // MARK: - WKNavigationDelegate
133 | func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
134 | needsMetadataRefresh()
135 | }
136 |
137 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
138 | needsMetadataRefresh()
139 | }
140 |
141 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
142 | if navigationAction.targetFrame?.isMainFrame ?? true,
143 | let block = shouldBlockNavigation,
144 | block(navigationAction) {
145 | decisionHandler(.cancel)
146 | return
147 | }
148 | decisionHandler(.allow)
149 | }
150 |
151 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
152 | waitingForRepopulationAfterProcessTerminate = true
153 | }
154 |
155 | // MARK: - WKUIDelegate
156 | func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
157 | // Load in same window:
158 | if let url = navigationAction.request.url {
159 | webview.load(.init(url: url))
160 | }
161 | return nil
162 | }
163 |
164 | // MARK: - Metadata
165 | private func needsMetadataRefresh() {
166 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
167 | self.refreshMetadataNow()
168 | }
169 | }
170 |
171 | private func refreshMetadataNow() {
172 | self.info = .init(url: webview.url, title: webview.title, canGoBack: webview.canGoBack, canGoForward: webview.canGoForward)
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/Sources/Reeeed/Utils/WebView/WebView.swift:
--------------------------------------------------------------------------------
1 | import WebKit
2 | import SwiftUI
3 | import Combine
4 |
5 | enum WebViewEvent: Equatable {
6 | struct ScrollInfo: Equatable {
7 | var contentOffset: CGPoint
8 | var contentSize: CGSize
9 | }
10 |
11 | case scrolledDown
12 | case scrolledUp
13 | case scrollPositionChanged(ScrollInfo)
14 | }
15 |
16 | #if os(macOS)
17 | struct WebView: NSViewRepresentable {
18 | typealias NSViewType = _WebViewContainer
19 |
20 | var content: WebContent
21 | var onEvent: ((WebViewEvent) -> Void)? = nil
22 |
23 | func makeNSView(context: Context) -> _WebViewContainer {
24 | return _WebViewContainer()
25 | }
26 |
27 | func updateNSView(_ nsView: _WebViewContainer, context: Context) {
28 | nsView.contentView = (content.view as! WKWebView)
29 | nsView.onEvent = onEvent
30 | }
31 | }
32 |
33 | class _WebViewContainer: NSView {
34 | var onEvent: ((WebViewEvent) -> Void)?
35 | // TODO: Implement scroll events
36 | private var webviewSubs = Set()
37 |
38 | var contentView: WKWebView? {
39 | didSet(old) {
40 | guard contentView != old else { return }
41 | webviewSubs.removeAll()
42 | old?.removeFromSuperview()
43 |
44 | if let view = contentView {
45 | addSubview(view)
46 | }
47 | }
48 | }
49 |
50 | override func layout() {
51 | super.layout()
52 | contentView?.frame = bounds
53 | }
54 | }
55 |
56 | #else
57 | struct WebView: UIViewRepresentable {
58 | typealias UIViewType = _WebViewContainer
59 |
60 | var content: WebContent
61 | var onEvent: ((WebViewEvent) -> Void)? = nil
62 |
63 | func makeUIView(context: Context) -> _WebViewContainer {
64 | return _WebViewContainer()
65 | }
66 |
67 | func updateUIView(_ uiView: _WebViewContainer, context: Context) {
68 | uiView.contentView = (content.view as! WKWebView)
69 | uiView.onEvent = onEvent
70 | }
71 | }
72 |
73 | class _WebViewContainer: UIView {
74 | var onEvent: ((WebViewEvent) -> Void)?
75 |
76 | var scrollPosRounded: CGFloat = 0 {
77 | didSet(old) {
78 | guard scrollPosRounded != old else { return }
79 | if scrollPosRounded < 50 {
80 | self.scrollDirection = -1 // up
81 | } else {
82 | self.scrollDirection = scrollPosRounded > old ? 1 : -1
83 | }
84 | }
85 | }
86 | var scrollDirection = 0 {
87 | didSet(old) {
88 | guard scrollDirection != old else { return }
89 | if scrollDirection == 1 {
90 | onEvent?(.scrolledDown)
91 | } else if scrollDirection == -1 {
92 | onEvent?(.scrolledUp)
93 | }
94 | }
95 | }
96 |
97 | private var webviewSubs = Set()
98 |
99 | var contentView: WKWebView? {
100 | didSet(old) {
101 | guard contentView != old else { return }
102 | webviewSubs.removeAll()
103 | old?.removeFromSuperview()
104 |
105 | if let view = contentView {
106 | addSubview(view)
107 | webviewSubs.insert(view.scrollView.observe(\.contentOffset, options: [.new]) { [weak self] scrollView, _change in
108 | self?.onEvent?(.scrollPositionChanged(scrollView.info))
109 | let offset = scrollView.info.contentOffset
110 | self?.scrollPosRounded = (offset.y / 40).rounded() * 40
111 | })
112 | webviewSubs.insert(view.scrollView.observe(\.contentSize, options: [.new]) { [weak self] scrollView, _change in
113 | self?.onEvent?(.scrollPositionChanged(scrollView.info))
114 | })
115 | }
116 | }
117 | }
118 |
119 | override func layoutSubviews() {
120 | super.layoutSubviews()
121 | contentView?.frame = bounds
122 | }
123 | }
124 |
125 | private extension UIScrollView {
126 | var info: WebViewEvent.ScrollInfo {
127 | return .init(contentOffset: contentOffset, contentSize: contentSize)
128 | }
129 | }
130 | #endif
131 |
--------------------------------------------------------------------------------
/Tests/ReeeedTests/ReeeedTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Reeeed
3 |
4 | final class ReeeedTests: XCTestCase {
5 | func testExample() throws {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 | XCTAssertEqual(Reeeed().text, "Hello, World!")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Reeeed: the reader mode from [feeeed](https://feeeed.nateparrott.com/)
2 |
3 | 
4 |
5 | `Reeeed` is a Swift implementation of Reader Mode: you give it the URL to an article on the web, it extracts the content — without navigation, or any other junk — and shows it to you in a standard format. It's faster, more consistent and less distracting than loading a full webpage. You can pass `Reeeed` a URL, and get back simple HTML to display. Or you can present the all-inclusive SwiftUI `ReeeederView` that handles everything for you.
6 |
7 | 
8 |
9 |
10 | ## Features
11 |
12 | - `ReeeederView`: a simple SwiftUI Reader View that works on iOS and macOS. Just pass a URL and present it.
13 | - `Reeeeder` extractor: pass a URL and receive cleaned HTML. You also get metadata, like the page's title, author and hero image.
14 | - The generated HTML supports **custom themes**. Default _and_ custom themes support **dark mode** out of the box.
15 |
16 | ## Installation
17 |
18 | 1. In Xcode's menu, click File → Swift Packages → Add Package Dependency...
19 | 2. Paste the URL of this repository: `https://github.com/nate-parrott/reeeed`
20 |
21 | Alternatively, add the dependency manually in your `Package.swift`: `.package(url: "https://github.com/nate-parrott/reeeed", from: "1.1.0")`
22 |
23 | ## Usage
24 |
25 | **Simplest implementation: `ReeeederView`**
26 |
27 | For the simplest integration, just present the batteries-included `ReeeederView`, like this:
28 |
29 | ```
30 | import SwiftUI
31 | import Reeeeder
32 |
33 | struct MyView: View {
34 | var body: some View {
35 | NavigationLink("Read Article") {
36 | ReeeederView(url: URL(string: "https://www.nytimes.com/2022/09/08/magazine/book-bans-texas.html")!)
37 | }
38 | }
39 | }
40 |
41 | ```
42 |
43 | `ReeeederView` also supports a dictionary of additional options:
44 |
45 |
46 | ```
47 | public struct ReeeederViewOptions {
48 | public var theme: ReaderTheme // Change the Reader Mode appearance
49 | public var onLinkClicked: ((URL) -> Void)?
50 | }
51 | ```
52 |
53 | **More flexible implementation**
54 |
55 | You can use `Reeeeder` to fetch article HTML directly:
56 |
57 | ```
58 | import Reeeeder
59 | import WebKit
60 | ...
61 | Task {
62 | do {
63 | let result = try await Reeeed.fetchAndExtractContent(fromURL: url, theme: options.theme)
64 | DispatchQueue.main.async {
65 | let webview = WKWebView()
66 | webview.load(loadHTMLString: result.styledHTML, baseURL: result.baseURL)
67 | // Show this webview onscreen
68 | }
69 | } catch {
70 | // We were unable to extract the content. You can show the normal URL in a webview instead :(
71 | }
72 | }
73 | ```
74 |
75 | If you have more specific needs — maybe want to fetch the HTML yourself, or wrap the extracted article HTML fragment in your own template — here's how to do it. Customize the code as necessary:
76 |
77 | ```
78 | Task {
79 | // Load the extractor (if necessary) concurrently while we fetch the HTML:
80 | DispatchQueue.main.async { Reeeed.warmup() }
81 |
82 | let (data, response) = try await URLSession.shared.data(from: url)
83 | guard let html = String(data: data, encoding: .utf8) else {
84 | throw ExtractionError.DataIsNotString
85 | }
86 | let baseURL = response.url ?? url
87 | // Extract the raw content:
88 | let content = try await Reeeed.extractArticleContent(url: baseURL, html: html)
89 | guard let extractedHTML = content.content else {
90 | throw ExtractionError.MissingExtractionData
91 | }
92 | // Extract the "Site Metadata" — title, hero image, etc
93 | let extractedMetadata = try? await SiteMetadata.extractMetadata(fromHTML: html, baseURL: baseURL)
94 | // Generated "styled html" you can show in a webview:
95 | let styledHTML = Reeeed.wrapHTMLInReaderStyling(html: extractedHTML, title: content.title ?? extractedMetadata?.title ?? "", baseURL: baseURL, author: content.author, heroImage: extractedMetadata?.heroImage, includeExitReaderButton: true, theme: theme)
96 | // OK, now display `styledHTML` in a webview.
97 | }
98 |
99 | ```
100 |
101 | ## How does it work?
102 |
103 | All the good libraries for extracting an article from a page, like [Mercury](https://github.com/postlight/parser) and [Readability](https://github.com/mozilla/readability), are written in Javascript. So `reeeed` opens a hidden webview, loads one of those parsers, and then uses it to process HTML. A page's full, messy HTML goes in, and — like magic — _just the content_ comes back out. You get consistent, simple HTML, and you get it fast.
104 |
105 | Of course, these libraries aren't perfect. If you give them a page that is not an article — or an article that's just _too_ messy — you'll get nothing. In that case, `reeeed` will fall back to displaying the full webpage.
106 |
107 | ## Updating the Postlight Parser (formerly Mercury) JS
108 |
109 | **Last updated September 18, 2022 (v2.2.2)**
110 |
111 | 1. Replace the `Sources/Reeeed/JS/mercury.web.js` file with a new one downloaded from [the project repo](https://github.com/postlight/parser/tree/main/dist)
112 | 2. Ensure the demo app works.
113 |
114 | ## Things I'd like to improve
115 |
116 | - [ ] Readability JS package is a few months old. They need to be updated. Ideally, this would be (semi) automated.
117 | - [ ] The API could use a bit of cleanup. The naming and code structure is a bit inconsistent.
118 | - [ ] Reeeed depends on two different HTML manipulation libraries: [SwiftSoup](https://github.com/scinfu/SwiftSoup) and [Fuzi](https://github.com/cezheng/Fuzi). Fuzi is much faster, so I'd like to migrate the remaining `SwiftSoup` code to use it ASAP, and remove the dependency.
119 | - [ ] Some day, I'd like to write a fully-native renderer for extracted content.
120 | - [ ] Tests would be nice 😊
121 |
--------------------------------------------------------------------------------