├── .gitignore ├── Images ├── mac.jpg └── phone.gif ├── LICENSE ├── Package.swift ├── ReeeedSample ├── ReeeedSample--iOS--Info.plist ├── ReeeedSample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── Shared │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ └── ReeeedSampleApp.swift └── macOS │ └── macOS.entitlements ├── Sources └── Reeeed │ ├── Extraction │ ├── ArticleContentExtraction.swift │ ├── MercuryExtractor.swift │ ├── ReadabilityExtractor.swift │ └── SiteMetadataExtraction.swift │ ├── JS │ ├── DO NOT OPEN THESE FILES IN XCODE.txt │ ├── mercury.web.js │ └── readability.bundle.min.js │ ├── ReadableDoc.swift │ ├── Reeeed.swift │ ├── UI │ ├── ReadableDoc+HTML.swift │ ├── ReaderPlaceholder.swift │ ├── ReaderTheme.swift │ └── ReeeederView.swift │ └── Utils │ ├── ColorExtraction.swift │ ├── Utils.swift │ └── WebView │ ├── WebContent.swift │ └── WebView.swift ├── Tests └── ReeeedTests │ └── ReeeedTests.swift └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | .DS_Store 6 | 7 | ## User settings 8 | xcuserdata/ 9 | 10 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 11 | *.xcscmblueprint 12 | *.xccheckout 13 | 14 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 15 | build/ 16 | DerivedData/ 17 | *.moved-aside 18 | *.pbxuser 19 | !default.pbxuser 20 | *.mode1v3 21 | !default.mode1v3 22 | *.mode2v3 23 | !default.mode2v3 24 | *.perspectivev3 25 | !default.perspectivev3 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | 30 | ## App packaging 31 | *.ipa 32 | *.dSYM.zip 33 | *.dSYM 34 | 35 | ## Playgrounds 36 | timeline.xctimeline 37 | playground.xcworkspace 38 | 39 | # Swift Package Manager 40 | # 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | # Package.resolved 45 | # *.xcodeproj 46 | # 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # 59 | # Pods/ 60 | # 61 | # Add this line if you want to avoid checking in source code from the Xcode workspace 62 | # *.xcworkspace 63 | 64 | # Carthage 65 | # 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # 77 | # It is recommended to not store the screenshots in the git repo. 78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 79 | # For more information about the recommended setup visit: 80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 81 | 82 | fastlane/report.xml 83 | fastlane/Preview.html 84 | fastlane/screenshots/**/*.png 85 | fastlane/test_output 86 | 87 | # Code Injection 88 | # 89 | # After new code Injection tools there's a generated folder /iOSInjectionProject 90 | # https://github.com/johnno1962/injectionforxcode 91 | 92 | iOSInjectionProject/ 93 | -------------------------------------------------------------------------------- /Images/mac.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nate-parrott/reeeed/7989f05206bd263d81cdb6324ca524cbe64b2c77/Images/mac.jpg -------------------------------------------------------------------------------- /Images/phone.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nate-parrott/reeeed/7989f05206bd263d81cdb6324ca524cbe64b2c77/Images/phone.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nate Parrott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Reeeed", 8 | platforms: [.iOS("15.0"), .macOS("12.0")], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "Reeeed", 13 | targets: ["Reeeed"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/cezheng/Fuzi", from: "3.1.3"), 17 | .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.4.3"), 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "Reeeed", 26 | dependencies: ["Fuzi", "SwiftSoup"], 27 | resources: [.process("JS")]), 28 | .testTarget( 29 | name: "ReeeedTests", 30 | dependencies: ["Reeeed"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /ReeeedSample/ReeeedSample--iOS--Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /ReeeedSample/ReeeedSample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 5F85075628CECE3700BC14A9 /* ReeeedSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F85074628CECE3600BC14A9 /* ReeeedSampleApp.swift */; }; 11 | 5F85075728CECE3700BC14A9 /* ReeeedSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F85074628CECE3600BC14A9 /* ReeeedSampleApp.swift */; }; 12 | 5F85075828CECE3700BC14A9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F85074728CECE3600BC14A9 /* ContentView.swift */; }; 13 | 5F85075928CECE3700BC14A9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F85074728CECE3600BC14A9 /* ContentView.swift */; }; 14 | 5F85075A28CECE3700BC14A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5F85074828CECE3700BC14A9 /* Assets.xcassets */; }; 15 | 5F85075B28CECE3700BC14A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5F85074828CECE3700BC14A9 /* Assets.xcassets */; }; 16 | 5F85076828CED71C00BC14A9 /* Reeeed in Frameworks */ = {isa = PBXBuildFile; productRef = 5F85076728CED71C00BC14A9 /* Reeeed */; }; 17 | 5F85076A28CEDAE100BC14A9 /* Reeeed in Frameworks */ = {isa = PBXBuildFile; productRef = 5F85076928CEDAE100BC14A9 /* Reeeed */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | 5F85074628CECE3600BC14A9 /* ReeeedSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReeeedSampleApp.swift; sourceTree = ""; }; 22 | 5F85074728CECE3600BC14A9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 23 | 5F85074828CECE3700BC14A9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 24 | 5F85074D28CECE3700BC14A9 /* ReeeedSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReeeedSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 25 | 5F85075328CECE3700BC14A9 /* ReeeedSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReeeedSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 5F85075528CECE3700BC14A9 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; 27 | 5F85076528CECE7700BC14A9 /* ReeeedSample--iOS--Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "ReeeedSample--iOS--Info.plist"; sourceTree = ""; }; 28 | 5F93EB6728D41B4B00A8ECEE /* reeeed */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = reeeed; path = ..; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | 5F85074A28CECE3700BC14A9 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | 5F85076828CED71C00BC14A9 /* Reeeed in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | 5F85075028CECE3700BC14A9 /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | 5F85076A28CEDAE100BC14A9 /* Reeeed in Frameworks */, 45 | ); 46 | runOnlyForDeploymentPostprocessing = 0; 47 | }; 48 | /* End PBXFrameworksBuildPhase section */ 49 | 50 | /* Begin PBXGroup section */ 51 | 5F85074028CECE3600BC14A9 = { 52 | isa = PBXGroup; 53 | children = ( 54 | 5F93EB6628D41B4B00A8ECEE /* Packages */, 55 | 5F85076528CECE7700BC14A9 /* ReeeedSample--iOS--Info.plist */, 56 | 5F85074528CECE3600BC14A9 /* Shared */, 57 | 5F85075428CECE3700BC14A9 /* macOS */, 58 | 5F85074E28CECE3700BC14A9 /* Products */, 59 | 5F85076628CED71C00BC14A9 /* Frameworks */, 60 | ); 61 | sourceTree = ""; 62 | }; 63 | 5F85074528CECE3600BC14A9 /* Shared */ = { 64 | isa = PBXGroup; 65 | children = ( 66 | 5F85074628CECE3600BC14A9 /* ReeeedSampleApp.swift */, 67 | 5F85074728CECE3600BC14A9 /* ContentView.swift */, 68 | 5F85074828CECE3700BC14A9 /* Assets.xcassets */, 69 | ); 70 | path = Shared; 71 | sourceTree = ""; 72 | }; 73 | 5F85074E28CECE3700BC14A9 /* Products */ = { 74 | isa = PBXGroup; 75 | children = ( 76 | 5F85074D28CECE3700BC14A9 /* ReeeedSample.app */, 77 | 5F85075328CECE3700BC14A9 /* ReeeedSample.app */, 78 | ); 79 | name = Products; 80 | sourceTree = ""; 81 | }; 82 | 5F85075428CECE3700BC14A9 /* macOS */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | 5F85075528CECE3700BC14A9 /* macOS.entitlements */, 86 | ); 87 | path = macOS; 88 | sourceTree = ""; 89 | }; 90 | 5F85076628CED71C00BC14A9 /* Frameworks */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | ); 94 | name = Frameworks; 95 | sourceTree = ""; 96 | }; 97 | 5F93EB6628D41B4B00A8ECEE /* Packages */ = { 98 | isa = PBXGroup; 99 | children = ( 100 | 5F93EB6728D41B4B00A8ECEE /* reeeed */, 101 | ); 102 | name = Packages; 103 | sourceTree = ""; 104 | }; 105 | /* End PBXGroup section */ 106 | 107 | /* Begin PBXNativeTarget section */ 108 | 5F85074C28CECE3700BC14A9 /* ReeeedSample (iOS) */ = { 109 | isa = PBXNativeTarget; 110 | buildConfigurationList = 5F85075E28CECE3700BC14A9 /* Build configuration list for PBXNativeTarget "ReeeedSample (iOS)" */; 111 | buildPhases = ( 112 | 5F85074928CECE3700BC14A9 /* Sources */, 113 | 5F85074A28CECE3700BC14A9 /* Frameworks */, 114 | 5F85074B28CECE3700BC14A9 /* Resources */, 115 | ); 116 | buildRules = ( 117 | ); 118 | dependencies = ( 119 | ); 120 | name = "ReeeedSample (iOS)"; 121 | packageProductDependencies = ( 122 | 5F85076728CED71C00BC14A9 /* Reeeed */, 123 | ); 124 | productName = "ReeeedSample (iOS)"; 125 | productReference = 5F85074D28CECE3700BC14A9 /* ReeeedSample.app */; 126 | productType = "com.apple.product-type.application"; 127 | }; 128 | 5F85075228CECE3700BC14A9 /* ReeeedSample (macOS) */ = { 129 | isa = PBXNativeTarget; 130 | buildConfigurationList = 5F85076128CECE3700BC14A9 /* Build configuration list for PBXNativeTarget "ReeeedSample (macOS)" */; 131 | buildPhases = ( 132 | 5F85074F28CECE3700BC14A9 /* Sources */, 133 | 5F85075028CECE3700BC14A9 /* Frameworks */, 134 | 5F85075128CECE3700BC14A9 /* Resources */, 135 | ); 136 | buildRules = ( 137 | ); 138 | dependencies = ( 139 | ); 140 | name = "ReeeedSample (macOS)"; 141 | packageProductDependencies = ( 142 | 5F85076928CEDAE100BC14A9 /* Reeeed */, 143 | ); 144 | productName = "ReeeedSample (macOS)"; 145 | productReference = 5F85075328CECE3700BC14A9 /* ReeeedSample.app */; 146 | productType = "com.apple.product-type.application"; 147 | }; 148 | /* End PBXNativeTarget section */ 149 | 150 | /* Begin PBXProject section */ 151 | 5F85074128CECE3600BC14A9 /* Project object */ = { 152 | isa = PBXProject; 153 | attributes = { 154 | BuildIndependentTargetsInParallel = 1; 155 | LastSwiftUpdateCheck = 1330; 156 | LastUpgradeCheck = 1330; 157 | TargetAttributes = { 158 | 5F85074C28CECE3700BC14A9 = { 159 | CreatedOnToolsVersion = 13.3.1; 160 | }; 161 | 5F85075228CECE3700BC14A9 = { 162 | CreatedOnToolsVersion = 13.3.1; 163 | }; 164 | }; 165 | }; 166 | buildConfigurationList = 5F85074428CECE3600BC14A9 /* Build configuration list for PBXProject "ReeeedSample" */; 167 | compatibilityVersion = "Xcode 13.0"; 168 | developmentRegion = en; 169 | hasScannedForEncodings = 0; 170 | knownRegions = ( 171 | en, 172 | Base, 173 | ); 174 | mainGroup = 5F85074028CECE3600BC14A9; 175 | productRefGroup = 5F85074E28CECE3700BC14A9 /* Products */; 176 | projectDirPath = ""; 177 | projectRoot = ""; 178 | targets = ( 179 | 5F85074C28CECE3700BC14A9 /* ReeeedSample (iOS) */, 180 | 5F85075228CECE3700BC14A9 /* ReeeedSample (macOS) */, 181 | ); 182 | }; 183 | /* End PBXProject section */ 184 | 185 | /* Begin PBXResourcesBuildPhase section */ 186 | 5F85074B28CECE3700BC14A9 /* Resources */ = { 187 | isa = PBXResourcesBuildPhase; 188 | buildActionMask = 2147483647; 189 | files = ( 190 | 5F85075A28CECE3700BC14A9 /* Assets.xcassets in Resources */, 191 | ); 192 | runOnlyForDeploymentPostprocessing = 0; 193 | }; 194 | 5F85075128CECE3700BC14A9 /* Resources */ = { 195 | isa = PBXResourcesBuildPhase; 196 | buildActionMask = 2147483647; 197 | files = ( 198 | 5F85075B28CECE3700BC14A9 /* Assets.xcassets in Resources */, 199 | ); 200 | runOnlyForDeploymentPostprocessing = 0; 201 | }; 202 | /* End PBXResourcesBuildPhase section */ 203 | 204 | /* Begin PBXSourcesBuildPhase section */ 205 | 5F85074928CECE3700BC14A9 /* Sources */ = { 206 | isa = PBXSourcesBuildPhase; 207 | buildActionMask = 2147483647; 208 | files = ( 209 | 5F85075828CECE3700BC14A9 /* ContentView.swift in Sources */, 210 | 5F85075628CECE3700BC14A9 /* ReeeedSampleApp.swift in Sources */, 211 | ); 212 | runOnlyForDeploymentPostprocessing = 0; 213 | }; 214 | 5F85074F28CECE3700BC14A9 /* Sources */ = { 215 | isa = PBXSourcesBuildPhase; 216 | buildActionMask = 2147483647; 217 | files = ( 218 | 5F85075928CECE3700BC14A9 /* ContentView.swift in Sources */, 219 | 5F85075728CECE3700BC14A9 /* ReeeedSampleApp.swift in Sources */, 220 | ); 221 | runOnlyForDeploymentPostprocessing = 0; 222 | }; 223 | /* End PBXSourcesBuildPhase section */ 224 | 225 | /* Begin XCBuildConfiguration section */ 226 | 5F85075C28CECE3700BC14A9 /* Debug */ = { 227 | isa = XCBuildConfiguration; 228 | buildSettings = { 229 | ALWAYS_SEARCH_USER_PATHS = NO; 230 | CLANG_ANALYZER_NONNULL = YES; 231 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 232 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 233 | CLANG_ENABLE_MODULES = YES; 234 | CLANG_ENABLE_OBJC_ARC = YES; 235 | CLANG_ENABLE_OBJC_WEAK = YES; 236 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 237 | CLANG_WARN_BOOL_CONVERSION = YES; 238 | CLANG_WARN_COMMA = YES; 239 | CLANG_WARN_CONSTANT_CONVERSION = YES; 240 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 241 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 242 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 243 | CLANG_WARN_EMPTY_BODY = YES; 244 | CLANG_WARN_ENUM_CONVERSION = YES; 245 | CLANG_WARN_INFINITE_RECURSION = YES; 246 | CLANG_WARN_INT_CONVERSION = YES; 247 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 248 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 249 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 250 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 251 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 252 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 253 | CLANG_WARN_STRICT_PROTOTYPES = YES; 254 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 255 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 256 | CLANG_WARN_UNREACHABLE_CODE = YES; 257 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 258 | COPY_PHASE_STRIP = NO; 259 | DEBUG_INFORMATION_FORMAT = dwarf; 260 | ENABLE_STRICT_OBJC_MSGSEND = YES; 261 | ENABLE_TESTABILITY = YES; 262 | GCC_C_LANGUAGE_STANDARD = gnu11; 263 | GCC_DYNAMIC_NO_PIC = NO; 264 | GCC_NO_COMMON_BLOCKS = YES; 265 | GCC_OPTIMIZATION_LEVEL = 0; 266 | GCC_PREPROCESSOR_DEFINITIONS = ( 267 | "DEBUG=1", 268 | "$(inherited)", 269 | ); 270 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 271 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 272 | GCC_WARN_UNDECLARED_SELECTOR = YES; 273 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 274 | GCC_WARN_UNUSED_FUNCTION = YES; 275 | GCC_WARN_UNUSED_VARIABLE = YES; 276 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 277 | MTL_FAST_MATH = YES; 278 | ONLY_ACTIVE_ARCH = YES; 279 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 280 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 281 | }; 282 | name = Debug; 283 | }; 284 | 5F85075D28CECE3700BC14A9 /* Release */ = { 285 | isa = XCBuildConfiguration; 286 | buildSettings = { 287 | ALWAYS_SEARCH_USER_PATHS = NO; 288 | CLANG_ANALYZER_NONNULL = YES; 289 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 290 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 291 | CLANG_ENABLE_MODULES = YES; 292 | CLANG_ENABLE_OBJC_ARC = YES; 293 | CLANG_ENABLE_OBJC_WEAK = YES; 294 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 295 | CLANG_WARN_BOOL_CONVERSION = YES; 296 | CLANG_WARN_COMMA = YES; 297 | CLANG_WARN_CONSTANT_CONVERSION = YES; 298 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 299 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 300 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 301 | CLANG_WARN_EMPTY_BODY = YES; 302 | CLANG_WARN_ENUM_CONVERSION = YES; 303 | CLANG_WARN_INFINITE_RECURSION = YES; 304 | CLANG_WARN_INT_CONVERSION = YES; 305 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 306 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 307 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 308 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 309 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 310 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 311 | CLANG_WARN_STRICT_PROTOTYPES = YES; 312 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 313 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 314 | CLANG_WARN_UNREACHABLE_CODE = YES; 315 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 316 | COPY_PHASE_STRIP = NO; 317 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 318 | ENABLE_NS_ASSERTIONS = NO; 319 | ENABLE_STRICT_OBJC_MSGSEND = YES; 320 | GCC_C_LANGUAGE_STANDARD = gnu11; 321 | GCC_NO_COMMON_BLOCKS = YES; 322 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 323 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 324 | GCC_WARN_UNDECLARED_SELECTOR = YES; 325 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 326 | GCC_WARN_UNUSED_FUNCTION = YES; 327 | GCC_WARN_UNUSED_VARIABLE = YES; 328 | MTL_ENABLE_DEBUG_INFO = NO; 329 | MTL_FAST_MATH = YES; 330 | SWIFT_COMPILATION_MODE = wholemodule; 331 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 332 | }; 333 | name = Release; 334 | }; 335 | 5F85075F28CECE3700BC14A9 /* Debug */ = { 336 | isa = XCBuildConfiguration; 337 | buildSettings = { 338 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 339 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 340 | CODE_SIGN_STYLE = Automatic; 341 | CURRENT_PROJECT_VERSION = 1; 342 | ENABLE_PREVIEWS = YES; 343 | GENERATE_INFOPLIST_FILE = YES; 344 | INFOPLIST_FILE = "ReeeedSample--iOS--Info.plist"; 345 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 346 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 347 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 348 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 349 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 350 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 351 | LD_RUNPATH_SEARCH_PATHS = ( 352 | "$(inherited)", 353 | "@executable_path/Frameworks", 354 | ); 355 | MARKETING_VERSION = 1.0; 356 | PRODUCT_BUNDLE_IDENTIFIER = com.nateparrott.ReeeedSample; 357 | PRODUCT_NAME = ReeeedSample; 358 | SDKROOT = iphoneos; 359 | SWIFT_EMIT_LOC_STRINGS = YES; 360 | SWIFT_VERSION = 5.0; 361 | TARGETED_DEVICE_FAMILY = "1,2"; 362 | }; 363 | name = Debug; 364 | }; 365 | 5F85076028CECE3700BC14A9 /* Release */ = { 366 | isa = XCBuildConfiguration; 367 | buildSettings = { 368 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 369 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 370 | CODE_SIGN_STYLE = Automatic; 371 | CURRENT_PROJECT_VERSION = 1; 372 | ENABLE_PREVIEWS = YES; 373 | GENERATE_INFOPLIST_FILE = YES; 374 | INFOPLIST_FILE = "ReeeedSample--iOS--Info.plist"; 375 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 376 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 377 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 378 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 379 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 380 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 381 | LD_RUNPATH_SEARCH_PATHS = ( 382 | "$(inherited)", 383 | "@executable_path/Frameworks", 384 | ); 385 | MARKETING_VERSION = 1.0; 386 | PRODUCT_BUNDLE_IDENTIFIER = com.nateparrott.ReeeedSample; 387 | PRODUCT_NAME = ReeeedSample; 388 | SDKROOT = iphoneos; 389 | SWIFT_EMIT_LOC_STRINGS = YES; 390 | SWIFT_VERSION = 5.0; 391 | TARGETED_DEVICE_FAMILY = "1,2"; 392 | VALIDATE_PRODUCT = YES; 393 | }; 394 | name = Release; 395 | }; 396 | 5F85076228CECE3700BC14A9 /* Debug */ = { 397 | isa = XCBuildConfiguration; 398 | buildSettings = { 399 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 400 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 401 | CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; 402 | CODE_SIGN_STYLE = Automatic; 403 | COMBINE_HIDPI_IMAGES = YES; 404 | CURRENT_PROJECT_VERSION = 1; 405 | ENABLE_PREVIEWS = YES; 406 | GENERATE_INFOPLIST_FILE = YES; 407 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 408 | LD_RUNPATH_SEARCH_PATHS = ( 409 | "$(inherited)", 410 | "@executable_path/../Frameworks", 411 | ); 412 | MACOSX_DEPLOYMENT_TARGET = 12.3; 413 | MARKETING_VERSION = 1.0; 414 | PRODUCT_BUNDLE_IDENTIFIER = com.nateparrott.ReeeedSample; 415 | PRODUCT_NAME = ReeeedSample; 416 | SDKROOT = macosx; 417 | SWIFT_EMIT_LOC_STRINGS = YES; 418 | SWIFT_VERSION = 5.0; 419 | }; 420 | name = Debug; 421 | }; 422 | 5F85076328CECE3700BC14A9 /* Release */ = { 423 | isa = XCBuildConfiguration; 424 | buildSettings = { 425 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 426 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 427 | CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; 428 | CODE_SIGN_STYLE = Automatic; 429 | COMBINE_HIDPI_IMAGES = YES; 430 | CURRENT_PROJECT_VERSION = 1; 431 | ENABLE_PREVIEWS = YES; 432 | GENERATE_INFOPLIST_FILE = YES; 433 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 434 | LD_RUNPATH_SEARCH_PATHS = ( 435 | "$(inherited)", 436 | "@executable_path/../Frameworks", 437 | ); 438 | MACOSX_DEPLOYMENT_TARGET = 12.3; 439 | MARKETING_VERSION = 1.0; 440 | PRODUCT_BUNDLE_IDENTIFIER = com.nateparrott.ReeeedSample; 441 | PRODUCT_NAME = ReeeedSample; 442 | SDKROOT = macosx; 443 | SWIFT_EMIT_LOC_STRINGS = YES; 444 | SWIFT_VERSION = 5.0; 445 | }; 446 | name = Release; 447 | }; 448 | /* End XCBuildConfiguration section */ 449 | 450 | /* Begin XCConfigurationList section */ 451 | 5F85074428CECE3600BC14A9 /* Build configuration list for PBXProject "ReeeedSample" */ = { 452 | isa = XCConfigurationList; 453 | buildConfigurations = ( 454 | 5F85075C28CECE3700BC14A9 /* Debug */, 455 | 5F85075D28CECE3700BC14A9 /* Release */, 456 | ); 457 | defaultConfigurationIsVisible = 0; 458 | defaultConfigurationName = Release; 459 | }; 460 | 5F85075E28CECE3700BC14A9 /* Build configuration list for PBXNativeTarget "ReeeedSample (iOS)" */ = { 461 | isa = XCConfigurationList; 462 | buildConfigurations = ( 463 | 5F85075F28CECE3700BC14A9 /* Debug */, 464 | 5F85076028CECE3700BC14A9 /* Release */, 465 | ); 466 | defaultConfigurationIsVisible = 0; 467 | defaultConfigurationName = Release; 468 | }; 469 | 5F85076128CECE3700BC14A9 /* Build configuration list for PBXNativeTarget "ReeeedSample (macOS)" */ = { 470 | isa = XCConfigurationList; 471 | buildConfigurations = ( 472 | 5F85076228CECE3700BC14A9 /* Debug */, 473 | 5F85076328CECE3700BC14A9 /* Release */, 474 | ); 475 | defaultConfigurationIsVisible = 0; 476 | defaultConfigurationName = Release; 477 | }; 478 | /* End XCConfigurationList section */ 479 | 480 | /* Begin XCSwiftPackageProductDependency section */ 481 | 5F85076728CED71C00BC14A9 /* Reeeed */ = { 482 | isa = XCSwiftPackageProductDependency; 483 | productName = Reeeed; 484 | }; 485 | 5F85076928CEDAE100BC14A9 /* Reeeed */ = { 486 | isa = XCSwiftPackageProductDependency; 487 | productName = Reeeed; 488 | }; 489 | /* End XCSwiftPackageProductDependency section */ 490 | }; 491 | rootObject = 5F85074128CECE3600BC14A9 /* Project object */; 492 | } 493 | -------------------------------------------------------------------------------- /ReeeedSample/ReeeedSample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ReeeedSample/ReeeedSample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ReeeedSample/ReeeedSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "fuzi", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/cezheng/Fuzi", 7 | "state" : { 8 | "revision" : "f08c8323da21e985f3772610753bcfc652c2103f", 9 | "version" : "3.1.3" 10 | } 11 | }, 12 | { 13 | "identity" : "swiftsoup", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/scinfu/SwiftSoup.git", 16 | "state" : { 17 | "revision" : "6778575285177365cbad3e5b8a72f2a20583cfec", 18 | "version" : "2.4.3" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /ReeeedSample/Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReeeedSample/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | }, 93 | { 94 | "idiom" : "mac", 95 | "scale" : "1x", 96 | "size" : "16x16" 97 | }, 98 | { 99 | "idiom" : "mac", 100 | "scale" : "2x", 101 | "size" : "16x16" 102 | }, 103 | { 104 | "idiom" : "mac", 105 | "scale" : "1x", 106 | "size" : "32x32" 107 | }, 108 | { 109 | "idiom" : "mac", 110 | "scale" : "2x", 111 | "size" : "32x32" 112 | }, 113 | { 114 | "idiom" : "mac", 115 | "scale" : "1x", 116 | "size" : "128x128" 117 | }, 118 | { 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "128x128" 122 | }, 123 | { 124 | "idiom" : "mac", 125 | "scale" : "1x", 126 | "size" : "256x256" 127 | }, 128 | { 129 | "idiom" : "mac", 130 | "scale" : "2x", 131 | "size" : "256x256" 132 | }, 133 | { 134 | "idiom" : "mac", 135 | "scale" : "1x", 136 | "size" : "512x512" 137 | }, 138 | { 139 | "idiom" : "mac", 140 | "scale" : "2x", 141 | "size" : "512x512" 142 | } 143 | ], 144 | "info" : { 145 | "author" : "xcode", 146 | "version" : 1 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /ReeeedSample/Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ReeeedSample/Shared/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Shared 4 | // 5 | // Created by nate parrott on 9/11/22. 6 | // 7 | 8 | import SwiftUI 9 | import Reeeed 10 | 11 | struct ContentView: View { 12 | @State private var showReaderForURL: IdentifiableURL? 13 | 14 | // TODO: Handle external links 15 | var body: some View { 16 | NavigationView { 17 | List { 18 | ArticleButton(title: "Test Article (NYT)", url: "https://www.nytimes.com/2022/09/08/magazine/book-bans-texas.html") 19 | ArticleButton(title: "Test Article (NYT, Custom Theme)", url: "https://www.nytimes.com/2022/09/08/magazine/book-bans-texas.html", theme: .serif) 20 | ArticleButton(title: "Test Article (Verge)", url: "https://www.theverge.com/2023/9/3/23857664/california-forever-tech-billionaire-secret-city") 21 | ArticleButton(title: "Test Article (image at top)", url: "https://9to5mac.com/2023/09/08/apple-iphone-15-event-what-to-expect/") 22 | ArticleButton(title: "Unextractable Page", url: "https://google.com") 23 | } 24 | .frame(minWidth: 200) 25 | } 26 | .navigationTitle("Reader Mode Sample") 27 | } 28 | } 29 | 30 | struct ArticleButton: View { 31 | var title: String 32 | var url: String 33 | var theme: ReaderTheme = .init() 34 | 35 | @State private var presented = false 36 | 37 | var body: some View { 38 | if isMac() { 39 | NavigationLink(title) { 40 | reader 41 | } 42 | } else { 43 | Button(title, action: { presented = true }) 44 | .sheet(isPresented: $presented) { 45 | reader 46 | } 47 | } 48 | } 49 | 50 | @ViewBuilder private var reader: some View { 51 | ReeeederView(url: URL(string: url)!, options: .init(theme: theme, onLinkClicked: linkClicked)) 52 | } 53 | 54 | private func linkClicked(_ url: URL) { 55 | #if os(macOS) 56 | NSWorkspace.shared.open(url) 57 | #else 58 | UIApplication.shared.open(url) 59 | #endif 60 | } 61 | } 62 | 63 | extension ReaderTheme { 64 | static let serif: ReaderTheme = .init(additionalCSS: """ 65 | body { 66 | font-family: serif; 67 | } 68 | """) 69 | } 70 | 71 | func isMac() -> Bool { 72 | #if os(macOS) 73 | return true 74 | #else 75 | return false 76 | #endif 77 | } 78 | 79 | private struct IdentifiableURL: Identifiable { 80 | var url: URL 81 | var id: String { url.absoluteString } 82 | } 83 | 84 | struct ContentView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | ContentView() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ReeeedSample/Shared/ReeeedSampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReeeedSampleApp.swift 3 | // Shared 4 | // 5 | // Created by nate parrott on 9/11/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ReeeedSampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ReeeedSample/macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/Reeeed/Extraction/ArticleContentExtraction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | import Fuzi 4 | 5 | public enum ExtractionError: Error { 6 | case DataIsNotString 7 | case FailedToExtract 8 | case MissingExtractionData 9 | } 10 | 11 | public struct ExtractedContent: Equatable, Codable { 12 | // See https://github.com/postlight/mercury-parser#usage 13 | public var content: String? 14 | public var author: String? 15 | public var title: String? 16 | public var excerpt: String? 17 | public var date_published: String? 18 | 19 | public init(content: String? = nil, author: String? = nil, title: String? = nil, excerpt: String? = nil, date_published: String? = nil) { 20 | self.content = content 21 | self.author = author 22 | self.title = title 23 | self.excerpt = excerpt 24 | self.date_published = date_published 25 | } 26 | } 27 | 28 | extension ExtractedContent { 29 | public var datePublished: Date? { 30 | date_published.flatMap { Self.dateParser.date(from: $0) } 31 | } 32 | static let dateParser = ISO8601DateFormatter() 33 | 34 | public var extractPlainText: String { 35 | if let content { 36 | let parsed = try? HTMLDocument(data: content.data(using: .utf8)!) 37 | var paragraphs = [""] 38 | let blockLevelTags = Set(["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 | ![Screen recording of the Reader Mode for iPhone](Images/phone.gif) 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 | ![Screenshot of the Reader Mode on Mac](Images/mac.jpg) 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 | --------------------------------------------------------------------------------