diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 5129853c..299a41c8 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -34,6 +34,9 @@ steps: queue: android plugins: *plugins - - label: ':swift: Test Swift Package' + - label: ':swift: Swift Simulator Tests' command: make test-swift-package plugins: *plugins + + - label: ':swift: Test Swift Logic' + command: swift test diff --git a/Makefile b/Makefile index d76d4cfa..c5bd0bd9 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -SIMULATOR_DESTINATION := OS=18.4,name=iPhone 16 Plus +SIMULATOR_DESTINATION := arch=arm64,OS=18.4,name=iPhone 16 Plus define XCODEBUILD_CMD @set -o pipefail && \ xcodebuild $(1) \ - -scheme GutenbergKit \ + -scheme GutenbergKit-Package \ -sdk iphonesimulator \ -destination '${SIMULATOR_DESTINATION}' \ | xcbeautify diff --git a/Package.swift b/Package.swift index 841672f6..ad8f7e9e 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,8 @@ let package = Package( name: "GutenbergKit", platforms: [.iOS(.v15), .macOS(.v14)], products: [ - .library(name: "GutenbergKit", targets: ["GutenbergKit"]) + .library(name: "GutenbergKit", targets: ["GutenbergKit"]), + .library(name: "GutenbergKitAssetManifestParser", targets: ["GutenbergKitAssetManifestParser"]) ], dependencies: [ .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.5"), @@ -15,19 +16,35 @@ let package = Package( targets: [ .target( name: "GutenbergKit", - dependencies: ["SwiftSoup"], + dependencies: [], path: "ios/Sources/GutenbergKit", exclude: [], resources: [.copy("Gutenberg")] ), + .target( + name: "GutenbergKitAssetManifestParser", + dependencies: ["GutenbergKit", "SwiftSoup"], + path: "ios/Sources/GutenbergKitAssetManifestParser", + exclude: [], + resources: [] + ), .testTarget( name: "GutenbergKitTests", dependencies: ["GutenbergKit"], - path: "ios/Tests", + path: "ios/Tests/GutenbergKitTests", exclude: [], resources: [ - .copy("GutenbergKitTests/Resources/manifest-test-case-1.json") + .copy("Resources/manifest-test-case-1.json") ] ), + .testTarget( + name: "GutenbergKitAssetManifestParserTests", + dependencies: ["GutenbergKitAssetManifestParser"], + path: "ios/Tests/GutenbergKitAssetManifestParserTests", + exclude: [], + resources: [ + .copy("../GutenbergKitTests/Resources/manifest-test-case-1.json") + ] + ) ] ) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 945cbdf1..473c04bb 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -13,6 +13,8 @@ 0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */; }; 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */; }; 0CF6E04C2BEFF60E00EDEE8A /* GutenbergKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */; }; + 24CA22122E72516A007E32F8 /* GutenbergKit in Frameworks */ = {isa = PBXBuildFile; productRef = 24CA22112E72516A007E32F8 /* GutenbergKit */; }; + 24CA22142E72516A007E32F8 /* GutenbergKitAssetManifestParser in Frameworks */ = {isa = PBXBuildFile; productRef = 24CA22132E72516A007E32F8 /* GutenbergKitAssetManifestParser */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,7 +24,6 @@ 0CE8E7862C339B0600B9DC67 /* EditorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergApp.swift; sourceTree = ""; }; 0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 0CE8E7922C339B1B00B9DC67 /* GutenbergKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GutenbergKit; path = ../..; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -31,6 +32,8 @@ buildActionMask = 2147483647; files = ( 0CF6E04C2BEFF60E00EDEE8A /* GutenbergKit in Frameworks */, + 24CA22142E72516A007E32F8 /* GutenbergKitAssetManifestParser in Frameworks */, + 24CA22122E72516A007E32F8 /* GutenbergKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -40,7 +43,6 @@ 0C4F59822BEFF4970028BD96 = { isa = PBXGroup; children = ( - 0CE8E7922C339B1B00B9DC67 /* GutenbergKit */, 0CE8E7882C339B0600B9DC67 /* Sources */, 0C83424D2C339B7F00CAA762 /* Resources */, 0CE8E78A2C339B0600B9DC67 /* PreviewContent */, @@ -108,6 +110,8 @@ name = Gutenberg; packageProductDependencies = ( 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */, + 24CA22112E72516A007E32F8 /* GutenbergKit */, + 24CA22132E72516A007E32F8 /* GutenbergKitAssetManifestParser */, ); productName = Gutenberg; productReference = 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */; @@ -137,6 +141,9 @@ Base, ); mainGroup = 0C4F59822BEFF4970028BD96; + packageReferences = ( + 24CA22102E72516A007E32F8 /* XCLocalSwiftPackageReference "../../../GutenbergKit" */, + ); productRefGroup = 0C4F598C2BEFF4970028BD96 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -377,11 +384,26 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 24CA22102E72516A007E32F8 /* XCLocalSwiftPackageReference "../../../GutenbergKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../GutenbergKit; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */ = { isa = XCSwiftPackageProductDependency; productName = GutenbergKit; }; + 24CA22112E72516A007E32F8 /* GutenbergKit */ = { + isa = XCSwiftPackageProductDependency; + productName = GutenbergKit; + }; + 24CA22132E72516A007E32F8 /* GutenbergKitAssetManifestParser */ = { + isa = XCSwiftPackageProductDependency; + productName = GutenbergKitAssetManifestParser; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 0C4F59832BEFF4970028BD96 /* Project object */; diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a469bac..7b669948 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,33 @@ { + "originHash" : "a3b24843c138e1e42cf2f4279a1fd7c271a7abd9fb3f1e59e0a6c3deb31fadfe", "pins" : [ + { + "identity" : "lrucache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/LRUCache.git", + "state" : { + "revision" : "e0e9e039b33db8f2ef39b8e25607e38f46b13584", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup.git", "state" : { - "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", - "version" : "2.8.8" + "revision" : "db0428bcfced386943c05ba8ea3e607baa715e45", + "version" : "2.11.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index ce9cd273..61fa837a 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import GutenbergKit +import GutenbergKitAssetManifestParser let editorURL: URL? = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) @@ -78,6 +79,7 @@ private extension EditorConfiguration { .setSiteApiRoot(siteApiRoot) .setEditorAssetsEndpoint(URL(string: siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets")) .setShouldUsePlugins(true) + .setEditorAssetManifestParser(GutenbergKitAssetManifestParser()) return configuration.build() } diff --git a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift index 6b57f9ca..333ec6ab 100644 --- a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift +++ b/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift @@ -16,11 +16,14 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { return components.url } - nonisolated static func cachedURL(forWebLink link: String) -> String? { - if link.starts(with: "http://") || link.starts(with: "https://") { - return cachedURLSchemePrefix + link + nonisolated static func cachedURL(for url: URL) -> URL { + if url.scheme == "http" || url.scheme == "https" { + var components = URLComponents(string: url.absoluteString)! + components.scheme = cachedURLSchemePrefix + (url.scheme ?? "") + return components.url! } - return nil + + return url } let worker: Worker @@ -105,4 +108,3 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { } } } - diff --git a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift index 9410320b..ab3958b0 100644 --- a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift @@ -1,11 +1,11 @@ import Foundation import CryptoKit -import SwiftSoup public actor EditorAssetsLibrary { enum ManifestError: Error { case unavailable case invalidServerResponse + case invalidSiteUrl } let urlSession: URLSession @@ -47,10 +47,14 @@ public actor EditorAssetsLibrary { /// - SeeAlso: `EditorAssetsLibrary.addAsset` func manifestContentForEditor() async throws -> Data { // For scheme-less links (i.e. '//stats.wp.com/w.js'), use the scheme in `siteURL`. - let siteURLScheme = URL(string: configuration.siteURL)?.scheme + guard let siteURLScheme = URL(string: configuration.siteURL)?.scheme else { + throw ManifestError.invalidSiteUrl + } + let data = try await loadManifestContent() - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: data) - return try manifest.renderForEditor(defaultScheme: siteURLScheme) + let manifest = try EditorAssetManifest(data: data) + + return try JSONEncoder().encode(manifest.applyingUrlScheme(siteURLScheme, using: configuration.assetManifestParser)) } /// Fetches all assets in the `EditorConfiguration.editorAssetsEndpoint` manifest and stores them on the device. @@ -61,23 +65,19 @@ public actor EditorAssetsLibrary { let siteURLScheme = URL(string: configuration.siteURL)?.scheme let data = try await loadManifestContent() - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: data) - let assetLinks = try manifest.parseAssetLinks(defaultScheme: siteURLScheme) - - for link in assetLinks { - guard let url = URL(string: link) else { - NSLog("Malformed asset link: \(link)") - continue - } + let manifest = try EditorAssetManifest(data: data) + .applyingUrlScheme(siteURLScheme, using: configuration.assetManifestParser) + let assetUrls = try manifest.getAllAssetUrls(using: configuration.assetManifestParser) + for url in assetUrls { guard url.scheme == "http" || url.scheme == "https" else { - NSLog("Unexpected asset link: \(link)") + NSLog("Unexpected asset link: \(url)") continue } - _ = try await cacheAsset(from: url) + try await cacheAsset(from: url) } - NSLog("\(assetLinks.count) resources processed.") + NSLog("\(assetUrls.count) resources processed.") } /// Fetches one asset (JavaScript or stylesheet) and caches its content on the device. @@ -85,6 +85,7 @@ public actor EditorAssetsLibrary { /// - Parameters: /// - httpURL: The javascript or css URL. /// - webViewURL: The corresponding URL requested by web view, which should the "GBK cache prefix" (`gbk-cache-https://`) + @discardableResult func cacheAsset(from httpURL: URL, webViewURL: URL? = nil) async throws -> (URLResponse, Data) { // The Web Inspector automatically requests ".js.map" files, we'll support it here for debugging purpose. let supportedResourceSuffixes = [".js", ".css", ".js.map"] @@ -191,10 +192,27 @@ private extension String { } } -struct EditorAssetsMainifest: Codable { - var scripts: String - var styles: String - var allowedBlockTypes: [String] +public struct EditorAssetSchemeResolver { + // Takes a URL string and applies the given scheme to it. + // + // If there is no scheme present, the `defaultScheme` will be applied to it. If no `defaultScheme` is + // provided, `https` will be used. + public static func resolveSchemeFor(_ link: String, defaultScheme: String?) -> String { + if link.starts(with: "//") { + return "\(defaultScheme ?? "https"):\(link)" + } + + return link + } +} + + +// An object representing the JSON response we receive from the server +// +public struct EditorAssetManifest: Codable { + public let scripts: String + public let styles: String + public let allowedBlockTypes: [String] enum CodingKeys: String, CodingKey { case scripts @@ -202,94 +220,78 @@ struct EditorAssetsMainifest: Codable { case allowedBlockTypes = "allowed_block_types" } - func parseAssetLinks(defaultScheme: String?) throws -> [String] { - let html = """ - - - \(scripts) - \(styles) - - - - """ - let document = try SwiftSoup.parse(html) - - var assetLinks: [String] = [] - assetLinks += try document.select("script[src]").map { - Self.resolveAssetLink(try $0.attr("src"), defaultScheme: defaultScheme) - } - assetLinks += try document.select(#"link[rel="stylesheet"][href]"#).map { - Self.resolveAssetLink(try $0.attr("href"), defaultScheme: defaultScheme) - } - return assetLinks + init(data: Data) throws { + self = try JSONDecoder().decode(EditorAssetManifest.self, from: data) } - func renderForEditor(defaultScheme: String?) throws -> Data { - var rendered = self - rendered.scripts = try Self.renderForEditor(scripts: self.scripts, defaultScheme: defaultScheme) - rendered.styles = try Self.renderForEditor(styles: self.styles, defaultScheme: defaultScheme) - return try JSONEncoder().encode(rendered) + init(scripts: String, styles: String, allowedBlockTypes: [String]) { + self.scripts = scripts + self.styles = styles + self.allowedBlockTypes = allowedBlockTypes } - private static func renderForEditor(scripts: String, defaultScheme: String?) throws -> String { - let html = """ - - - \(scripts) - - - - """ - let document = try SwiftSoup.parse(html) - - for script in try document.select("script[src]") { - if let src = try? script.attr("src") { - let link = Self.resolveAssetLink(src, defaultScheme: defaultScheme) - #if canImport(UIKit) - let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link - #else - let newLink = link - #endif - try script.attr("src", newLink) - } - } + func getScriptUrlStrings(using parser: EditorAssetManifestParser) throws -> [String] { + try parser.extractScriptURLs(from: self.scripts) + } - let head = document.head()! - return try head.html() + func getScriptUrls(using parser: EditorAssetManifestParser) throws -> [URL] { + try getScriptUrlStrings(using: parser).compactMap(URL.init) } - private static func renderForEditor(styles: String, defaultScheme: String?) throws -> String { - let html = """ - - - \(styles) - - - - """ - let document = try SwiftSoup.parse(html) - - for stylesheet in try document.select(#"link[rel="stylesheet"][href]"#) { - if let href = try? stylesheet.attr("href") { - let link = Self.resolveAssetLink(href, defaultScheme: defaultScheme) - #if canImport(UIKit) - let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link - #else - let newLink = link - #endif - try stylesheet.attr("href", newLink) - } + func getStyleUrlStrings(using parser: EditorAssetManifestParser) throws -> [String] { + try parser.extractStyleURLs(from: self.styles) + } + + func getStyleUrls(using parser: EditorAssetManifestParser) throws -> [URL] { + try getStyleUrlStrings(using: parser).compactMap(URL.init) + } + + func getAllAssetUrls(applyingDefaultScheme scheme: String? = nil, using parser: EditorAssetManifestParser) throws -> [URL] { + let scriptUrls = try self.getScriptUrls(using: parser) + let styleUrls = try self.getStyleUrls(using: parser) + + return scriptUrls + styleUrls + } + + func applyingUrlScheme(_ newScheme: String?, using manifestParser: EditorAssetManifestParser) throws -> Self { + var mutableStyles = self.styles + var mutableScripts = self.scripts + + for rawLink in try getStyleUrlStrings(using: manifestParser) { + let resolvedLink = EditorAssetSchemeResolver.resolveSchemeFor(rawLink, defaultScheme: newScheme) + mutableStyles = mutableStyles.replacingOccurrences(of: rawLink, with: resolvedLink) + } + + for rawLink in try getScriptUrlStrings(using: manifestParser) { + let resolvedLink = EditorAssetSchemeResolver.resolveSchemeFor(rawLink, defaultScheme: newScheme) + mutableScripts = mutableScripts.replacingOccurrences(of: rawLink, with: resolvedLink) } - let head = document.head()! - return try head.html() + return EditorAssetManifest( + scripts: mutableScripts, + styles: mutableStyles, + allowedBlockTypes: self.allowedBlockTypes + ) } - private static func resolveAssetLink(_ link: String, defaultScheme: String?) -> String { - if link.starts(with: "//") { - return "\(defaultScheme ?? "https"):\(link)" + func resolvingCachedUrls(using manifestParser: EditorAssetManifestParser) throws -> Self { + var mutableStyles = self.styles + var mutableScripts = self.scripts + + for url in try getStyleUrls(using: manifestParser) { + let cachedLink = CachedAssetSchemeHandler.cachedURL(for: url) + mutableStyles = mutableStyles.replacingOccurrences(of: url.absoluteString, with: cachedLink.absoluteString) } - return link + for url in try getScriptUrls(using: manifestParser) { + let cachedLink = CachedAssetSchemeHandler.cachedURL(for: url) + mutableScripts = mutableScripts.replacingOccurrences(of: url.absoluteString, with: cachedLink.absoluteString) + } + + return EditorAssetManifest( + scripts: mutableScripts, + styles: mutableStyles, + allowedBlockTypes: self.allowedBlockTypes + ) } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index d66301c3..e4995e81 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -38,6 +38,9 @@ public struct EditorConfiguration { // Cookies public let cookies: [HTTPCookie] + // Parses URLs out of the Asset Manifest HTML + public var assetManifestParser: EditorAssetManifestParser + /// Deliberately non-public – consumers should use `EditorConfigurationBuilder` to construct a configuration init( title: String, @@ -56,7 +59,8 @@ public struct EditorConfiguration { editorSettings: EditorSettings, locale: String, editorAssetsEndpoint: URL? = nil, - cookies: [HTTPCookie] = [] + cookies: [HTTPCookie] = [], + assetManifestParser: EditorAssetManifestParser ) { self.title = title self.content = content @@ -75,6 +79,7 @@ public struct EditorConfiguration { self.locale = locale self.editorAssetsEndpoint = editorAssetsEndpoint self.cookies = cookies + self.assetManifestParser = assetManifestParser } public func toBuilder() -> EditorConfigurationBuilder { @@ -94,7 +99,9 @@ public struct EditorConfiguration { webViewGlobals: webViewGlobals, editorSettings: editorSettings, locale: locale, - editorAssetsEndpoint: editorAssetsEndpoint + editorAssetsEndpoint: editorAssetsEndpoint, + cookies: cookies, + assetManifestParser: assetManifestParser ) } @@ -112,7 +119,7 @@ public struct EditorConfiguration { return String(data: jsonData, encoding: .utf8) ?? "undefined" } - public static let `default` = EditorConfigurationBuilder().build() + public static var `default` = EditorConfigurationBuilder().build() } public struct EditorConfigurationBuilder { @@ -132,6 +139,8 @@ public struct EditorConfigurationBuilder { private var editorSettings: EditorSettings private var locale: String private var editorAssetsEndpoint: URL? + private var cookies: [HTTPCookie] + private var assetManifestParser: EditorAssetManifestParser public init( title: String = "", @@ -149,7 +158,9 @@ public struct EditorConfigurationBuilder { webViewGlobals: [WebViewGlobal] = [], editorSettings: EditorSettings = [:], locale: String = "en", - editorAssetsEndpoint: URL? = nil + editorAssetsEndpoint: URL? = nil, + cookies: [HTTPCookie] = [], + assetManifestParser: EditorAssetManifestParser = AssetManifestParserProvider.default ){ self.title = title self.content = content @@ -167,6 +178,8 @@ public struct EditorConfigurationBuilder { self.editorSettings = editorSettings self.locale = locale self.editorAssetsEndpoint = editorAssetsEndpoint + self.cookies = cookies + self.assetManifestParser = assetManifestParser } public func setTitle(_ title: String) -> EditorConfigurationBuilder { @@ -265,6 +278,18 @@ public struct EditorConfigurationBuilder { return copy } + public func setCookies(_ cookies: [HTTPCookie]) -> EditorConfigurationBuilder { + var copy = self + copy.cookies = cookies + return copy + } + + public func setEditorAssetManifestParser(_ manifestParser: EditorAssetManifestParser) -> EditorConfigurationBuilder { + var copy = self + copy.assetManifestParser = manifestParser + return copy + } + public func build() -> EditorConfiguration { EditorConfiguration( title: title, @@ -282,7 +307,9 @@ public struct EditorConfigurationBuilder { webViewGlobals: webViewGlobals, editorSettings: editorSettings, locale: locale, - editorAssetsEndpoint: editorAssetsEndpoint + editorAssetsEndpoint: editorAssetsEndpoint, + cookies: cookies, + assetManifestParser: assetManifestParser ) } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index e22b4dfe..e691c4d2 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -21,6 +21,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public weak var delegate: EditorViewControllerDelegate? + public var assetManifestParser: EditorAssetManifestParser? + private var cancellables: [AnyCancellable] = [] /// Warmup mode preloads resources into memory to make the UI transition seamless when displaying the editor for the first time diff --git a/ios/Sources/GutenbergKit/Sources/Logger.swift b/ios/Sources/GutenbergKit/Sources/Logger.swift new file mode 100644 index 00000000..a2e0e411 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Logger.swift @@ -0,0 +1,9 @@ +import Foundation + +#if canImport(OSLog) +import OSLog + +extension Logger { + static let gbkit = Logger(subsystem: "org.wordpress.gutenberg", category: "all") +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Protocols/EditorAssetManifestParser.swift b/ios/Sources/GutenbergKit/Sources/Protocols/EditorAssetManifestParser.swift new file mode 100644 index 00000000..66ad245d --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Protocols/EditorAssetManifestParser.swift @@ -0,0 +1,37 @@ +import Foundation +import RegexBuilder +import OSLog + +public protocol EditorAssetManifestParser { + func extractStyleURLs(from html: String) throws -> [String] + func extractScriptURLs(from html: String) throws -> [String] +} + +// A default dependency-free manifest transformer that uses Regex. For better performance and error-handling, +// use a real HTML parser (included in this package) +@available(iOS 16.0, *) +public class DefaultEditorAssetManifestParser: EditorAssetManifestParser { + + static let styles = #/]+href=['"](?'url'[^'"]+)['"]/# + static let scripts = #/]+src=['"](?'url'[^'"]+)['"]/# + + public func extractStyleURLs(from html: String) throws -> [String] { + return html.matches(of: Self.styles).map { String($0.output.url) } + } + + public func extractScriptURLs(from html: String) throws -> [String] { + return html.matches(of: Self.scripts).map { String($0.output.url) } + } +} + +public struct AssetManifestParserProvider { + public static var `default`: EditorAssetManifestParser { + if #available(iOS 16.0, *) { + Logger.gbkit.warning("Warning: using the default `AssetManifestParser` – this is not recommended. You can use the included `SwiftSoupAssetManifestParser` or create your own.") + + return DefaultEditorAssetManifestParser() + } else { + preconditionFailure("You must provide an implementation of `EditorAssetManifestParser`. You can use the included `SwiftSoupAssetManifestParser` or create your own.") + } + } +} diff --git a/ios/Sources/GutenbergKitAssetManifestParser/GutenbergKitAssetManifestParser.swift b/ios/Sources/GutenbergKitAssetManifestParser/GutenbergKitAssetManifestParser.swift new file mode 100644 index 00000000..3133257f --- /dev/null +++ b/ios/Sources/GutenbergKitAssetManifestParser/GutenbergKitAssetManifestParser.swift @@ -0,0 +1,40 @@ +import Foundation +import GutenbergKit +import SwiftSoup + +public struct GutenbergKitAssetManifestParser: EditorAssetManifestParser { + + public init(){} + + public func extractStyleURLs(from html: String) throws -> [String] { + let html = """ + + + \(html) + + + + """ + let document = try SwiftSoup.parse(html) + + return try document.select(#"link[rel="stylesheet"][href]"#).map { + try $0.attr("href") + } + } + + public func extractScriptURLs(from html: String) throws -> [String] { + let html = """ + + + \(html) + + + + """ + let document = try SwiftSoup.parse(html) + + return try document.select(#"script[src]"#).map { + try $0.attr("src") + } + } +} diff --git a/ios/Tests/GutenbergKitAssetManifestParserTests/GutenbergKitAssetManifestParserTests.swift b/ios/Tests/GutenbergKitAssetManifestParserTests/GutenbergKitAssetManifestParserTests.swift new file mode 100644 index 00000000..762b1fe3 --- /dev/null +++ b/ios/Tests/GutenbergKitAssetManifestParserTests/GutenbergKitAssetManifestParserTests.swift @@ -0,0 +1,28 @@ +import Testing +import GutenbergKit +import GutenbergKitAssetManifestParser + +class GutenbergKitAssetManifestParserTestsTest { + + let parser = GutenbergKitAssetManifestParser() + + @Test func testScriptParsing() async throws { + let urls = try parser.extractScriptURLs(from: #""\n"#) + + #expect(urls.count == 3) + + for url in urls { + #expect(url == "http://localhost/wp-includes/js/dist/primitives.min.js?ver=aef2543ab60c8c9bb609") + } + } + + @Test func testStyleParsing() async throws { + let urls = try parser.extractStyleURLs(from: #"\n\n"#) + + #expect(urls.count == 3) + + for url in urls { + #expect(url == "http://localhost/wp-includes/css/dist/components/style.min.css?ver=6.7.2") + } + } +} diff --git a/ios/Tests/GutenbergKitTests/DefaultEditorAssetManifestParserTests.swift b/ios/Tests/GutenbergKitTests/DefaultEditorAssetManifestParserTests.swift new file mode 100644 index 00000000..962e1c56 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/DefaultEditorAssetManifestParserTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import GutenbergKit + +class DefaultEditorAssetManifestParserTest { + + let parser: EditorAssetManifestParser = AssetManifestParserProvider.default + + @Test func testScriptParsing() async throws { + let urls = try parser.extractScriptURLs(from: #""\n"#) + + #expect(urls.count == 3) + + for url in urls { + #expect(url == "http://localhost/wp-includes/js/dist/primitives.min.js?ver=aef2543ab60c8c9bb609") + } + } + + @Test func testStyleParsing() async throws { + let urls = try parser.extractStyleURLs(from: #"\n\n"#) + + #expect(urls.count == 3) + + for url in urls { + #expect(url == "http://localhost/wp-includes/css/dist/components/style.min.css?ver=6.7.2") + } + } +} diff --git a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift index 5a74014d..48aafa3b 100644 --- a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift @@ -5,48 +5,49 @@ import Testing @Suite("Manifest Tests") struct EditorManifestTests { + let parser: EditorAssetManifestParser = AssetManifestParserProvider.default + @Test func parseAssetLinks() throws { let json = try json(named: "manifest-test-case-1") - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: json) + let manifest = try EditorAssetManifest(data: json) - let links = try manifest.parseAssetLinks(defaultScheme: nil) - let scripts = links.filter { $0.contains(".js") } - let styles = links.filter { $0.contains(".css") } + let scripts = try manifest.getScriptUrls(using: parser) + let styles = try manifest.getStyleUrls(using: parser) - #expect(scripts.count == 79) + #expect(scripts.count == 80) #expect(styles.count == 22) } @Test func editorWebViewGetsCachedLinks() throws { let json = try json(named: "manifest-test-case-1") - let original = try JSONDecoder().decode(EditorAssetsMainifest.self, from: json) - let forEditor = try JSONDecoder().decode(EditorAssetsMainifest.self, from: original.renderForEditor(defaultScheme: nil)) + let original = try EditorAssetManifest(data: json) + let forEditor = try original.resolvingCachedUrls(using: parser) - #expect(try original.parseAssetLinks(defaultScheme: nil).count == forEditor.parseAssetLinks(defaultScheme: nil).count) + let originalAssetUrls = try original.getAllAssetUrls(using: parser) + let editorAssetUrls = try forEditor.getAllAssetUrls(using: parser) - for link in try original.parseAssetLinks(defaultScheme: nil) { - #expect(link.hasPrefix("http://")) - } + #expect(originalAssetUrls.count == editorAssetUrls.count) - #if canImport(UIKit) - for link in try forEditor.parseAssetLinks(defaultScheme: nil) { - #expect(link.hasPrefix("gbk-cache-http://")) + for link in originalAssetUrls { + #expect(link.scheme == "http") } - #else - for link in try forEditor.parseAssetLinks(defaultScheme: nil) { - #expect(link.hasPrefix("http://")) + + for link in editorAssetUrls { + #expect(link.scheme == "gbk-cache-http") } - #endif } @Test func useDefaultScheme() throws { let scriptHTML = #""# - let manifest = EditorAssetsMainifest(scripts: scriptHTML, styles: "", allowedBlockTypes: []) - #expect(try manifest.parseAssetLinks(defaultScheme: "http") == ["http://w.org/lib.js"]) - #expect(try manifest.parseAssetLinks(defaultScheme: "https") == ["https://w.org/lib.js"]) + let defaultManifest = EditorAssetManifest(scripts: scriptHTML, styles: "", allowedBlockTypes: []) + let httpManifest = try defaultManifest.applyingUrlScheme("http", using: parser) + let httpsManifest = try defaultManifest.applyingUrlScheme("https", using: parser) + + #expect(try httpManifest.getScriptUrlStrings(using: parser) == ["http://w.org/lib.js"]) + #expect(try httpsManifest.getScriptUrlStrings(using: parser) == ["https://w.org/lib.js"]) } private func json(named name: String) throws -> Data {