From 67a17b1f74da384a89f06a03d924b196070089b8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 30 May 2025 11:41:58 -0600 Subject: [PATCH 1/9] Add EditorConfigurationBuilder --- ios/Demo-iOS/Sources/ContentView.swift | 22 +- .../Sources/EditorConfiguration.swift | 362 +++++++++++++++++- .../Sources/EditorViewController.swift | 34 +- 3 files changed, 367 insertions(+), 51 deletions(-) diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index 747107a2..ce9cd273 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -66,20 +66,20 @@ struct ContentView: View { private extension EditorConfiguration { static var template: Self { - var configuration = EditorConfiguration.default - - #warning("1. Update the property values below") + #warning("1. Update the siteURL and authHeader values below") #warning("2. Install the Jetpack plugin to the site") - configuration.siteURL = "https://modify-me.com" - configuration.authHeader = "Insert the Authorization header value here" + let siteUrl: String = "https://modify-me.com" + let authHeader: String = "Insert the Authorization header value here" + let siteApiRoot: String = "\(siteUrl)/wp-json/" - // DO NOT CHANGE the properties below - configuration.siteApiRoot = "\(configuration.siteURL)/wp-json/" - configuration.editorAssetsEndpoint = URL(string: configuration.siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets") - // The `plugins: true` is necessary for the editor to use 'remote.html' - configuration.plugins = true + let configuration = EditorConfigurationBuilder() + .setSiteUrl(siteUrl) + .setAuthHeader(authHeader) + .setSiteApiRoot(siteApiRoot) + .setEditorAssetsEndpoint(URL(string: siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets")) + .setShouldUsePlugins(true) - return configuration + return configuration.build() } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index 11a739fc..f9c6b4cb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -2,34 +2,358 @@ import Foundation public struct EditorConfiguration { /// The initial title to initialize the editor with. - public var title = "" + public let title: String /// The initial content to initialize the editor with. - public var content = "" - - public var postID: Int? - public var postType: String? - public var themeStyles = false - public var plugins = false - public var hideTitle = false - public var siteURL = "" - public var siteApiRoot = "" - public var siteApiNamespace: [String] = [] - public var namespaceExcludedPaths: [String] = [] - public var authHeader = "" + public let content: String + + public let postID: Int? + public let postType: String? + public let shouldUseThemeStyles: Bool + public let shouldUsePlugins: Bool + public let shouldHideTitle: Bool + public let siteURL: String + public let siteApiRoot: String + public let siteApiNamespace: [String] + public let namespaceExcludedPaths: [String] + public let authHeader: String + + public let webViewGlobals: [WebViewGlobal] + /// Raw block editor settings from the WordPress REST API - public var editorSettings: [String: Any]? + public let editorSettings: EditorSettings + /// The locale to use for translations - public var locale = "en" + public let locale: String public var editorAssetsEndpoint: URL? - public init(title: String = "", content: String = "") { + // Cookies + public let cookies: [HTTPCookie] + + /// Deliberately non-public – consumers should use `EditorConfigurationBuilder` to construct a configuration + init( + title: String, + content: String, + postID: Int?, + postType: String?, + shouldUseThemeStyles: Bool, + shouldUsePlugins: Bool, + shouldHideTitle: Bool, + siteURL: String, + siteApiRoot: String, + siteApiNamespace: [String], + namespaceExcludedPaths: [String], + authHeader: String, + webViewGlobals: [WebViewGlobal], + editorSettings: EditorSettings, + locale: String, + editorAssetsEndpoint: URL? = nil, + cookies: [HTTPCookie] = [] + ) { self.title = title self.content = content + self.postID = postID + self.postType = postType + self.shouldUseThemeStyles = shouldUseThemeStyles + self.shouldUsePlugins = shouldUsePlugins + self.shouldHideTitle = shouldHideTitle + self.siteURL = siteURL + self.siteApiRoot = siteApiRoot + self.siteApiNamespace = siteApiNamespace + self.namespaceExcludedPaths = namespaceExcludedPaths + self.authHeader = authHeader + self.webViewGlobals = webViewGlobals + self.editorSettings = editorSettings + self.locale = locale + self.editorAssetsEndpoint = editorAssetsEndpoint + self.cookies = cookies + } + + public func toBuilder() -> EditorConfigurationBuilder { + return EditorConfigurationBuilder( + title: title, + content: content, + postID: postID, + postType: postType, + shouldUseThemeStyles: shouldUseThemeStyles, + shouldUsePlugins: shouldUsePlugins, + shouldHideTitle: shouldHideTitle, + siteURL: siteURL, + siteApiRoot: siteApiRoot, + siteApiNamespace: siteApiNamespace, + namespaceExcludedPaths: namespaceExcludedPaths, + authHeader: authHeader, + webViewGlobals: webViewGlobals, + editorSettings: editorSettings, + locale: locale, + editorAssetsEndpoint: editorAssetsEndpoint, + cookies: cookies + ) + } + + var escapedTitle: String { + title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! } - public mutating func updateEditorSettings(_ settings: [String: Any]?) { - self.editorSettings = settings + var escapedContent: String { + content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + } + + var editorSettingsJSON: String { + // `editorSettings` values are always `encodable` so this should never fail + let jsonData = try! JSONSerialization.data(withJSONObject: editorSettings, options: []) + return String(data: jsonData, encoding: .utf8) ?? "undefined" + } + + public static let `default` = EditorConfigurationBuilder().build() +} + +public struct EditorConfigurationBuilder { + private var title: String + private var content: String + private var postID: Int? + private var postType: String? + private var shouldUseThemeStyles: Bool + private var shouldUsePlugins: Bool + private var shouldHideTitle: Bool + private var siteURL: String + private var siteApiRoot: String + private var siteApiNamespace: [String] + private var namespaceExcludedPaths: [String] + private var authHeader: String + private var webViewGlobals: [WebViewGlobal] + private var editorSettings: EditorSettings + private var locale: String + private var editorAssetsEndpoint: URL? + private var cookies: [HTTPCookie] + + public init( + title: String = "", + content: String = "", + postID: Int? = nil, + postType: String? = nil, + shouldUseThemeStyles: Bool = false, + shouldUsePlugins: Bool = false, + shouldHideTitle: Bool = false, + siteURL: String = "", + siteApiRoot: String = "", + siteApiNamespace: [String] = [], + namespaceExcludedPaths: [String] = [], + authHeader: String = "", + webViewGlobals: [WebViewGlobal] = [], + editorSettings: EditorSettings = [:], + locale: String = "en", + editorAssetsEndpoint: URL? = nil, + cookies: [HTTPCookie] = [] + ){ + self.title = title + self.content = content + self.postID = postID + self.postType = postType + self.shouldUseThemeStyles = shouldUseThemeStyles + self.shouldUsePlugins = shouldUsePlugins + self.shouldHideTitle = shouldHideTitle + self.siteURL = siteURL + self.siteApiRoot = siteApiRoot + self.siteApiNamespace = siteApiNamespace + self.namespaceExcludedPaths = namespaceExcludedPaths + self.authHeader = authHeader + self.webViewGlobals = webViewGlobals + self.editorSettings = editorSettings + self.locale = locale + self.editorAssetsEndpoint = editorAssetsEndpoint + self.cookies = cookies } - public static let `default` = EditorConfiguration() + public func setTitle(_ title: String) -> EditorConfigurationBuilder { + var copy = self + copy.title = title + return copy + } + + public func setContent(_ content: String) -> EditorConfigurationBuilder { + var copy = self + copy.content = content + return copy + } + + public func setPostID(_ postID: Int?) -> EditorConfigurationBuilder { + var copy = self + copy.postID = postID + return copy + } + + public func setPostType(_ postType: String?) -> EditorConfigurationBuilder { + var copy = self + copy.postType = postType + return copy + } + + public func setShouldUseThemeStyles(_ shouldUseThemeStyles: Bool) -> EditorConfigurationBuilder { + var copy = self + copy.shouldUseThemeStyles = shouldUseThemeStyles + return copy + } + + public func setShouldUsePlugins(_ shouldUsePlugins: Bool) -> EditorConfigurationBuilder { + var copy = self + copy.shouldUsePlugins = shouldUsePlugins + return copy + } + + public func setShouldHideTitle(_ shouldHideTitle: Bool) -> EditorConfigurationBuilder { + var copy = self + copy.shouldHideTitle = shouldHideTitle + return copy + } + + public func setSiteUrl(_ siteUrl: String) -> EditorConfigurationBuilder { + var copy = self + copy.siteURL = siteUrl + return copy + } + + public func setSiteApiRoot(_ siteApiRoot: String) -> EditorConfigurationBuilder { + var copy = self + copy.siteApiRoot = siteApiRoot + return copy + } + + public func setSiteApiNamespace(_ siteApiNamespace: [String]) -> EditorConfigurationBuilder { + var copy = self + copy.siteApiNamespace = siteApiNamespace + return copy + } + + public func setNamespaceExcludedPaths(_ namespaceExcludedPaths: [String]) -> EditorConfigurationBuilder { + var copy = self + copy.namespaceExcludedPaths = namespaceExcludedPaths + return copy + } + + public func setAuthHeader(_ authHeader: String) -> EditorConfigurationBuilder { + var copy = self + copy.authHeader = authHeader + return copy + } + + public func setWebViewGlobals(_ webViewGlobals: [WebViewGlobal]) -> EditorConfigurationBuilder { + var copy = self + copy.webViewGlobals = webViewGlobals + return copy + } + + public func setEditorSettings(_ editorSettings: EditorSettings) -> EditorConfigurationBuilder { + var copy = self + copy.editorSettings = editorSettings + return copy + } + + public func setLocale(_ locale: String) -> EditorConfigurationBuilder { + var copy = self + copy.locale = locale + return copy + } + + public func setEditorAssetsEndpoint(_ editorAssetsEndpoint: URL?) -> EditorConfigurationBuilder { + var copy = self + copy.editorAssetsEndpoint = editorAssetsEndpoint + return copy + } + + public func setCookies(_ cookies: [HTTPCookie]) -> EditorConfigurationBuilder { + var copy = self + copy.cookies = cookies + return copy + } + + public func build() -> EditorConfiguration { + EditorConfiguration( + title: title, + content: content, + postID: postID, + postType: postType, + shouldUseThemeStyles: shouldUseThemeStyles, + shouldUsePlugins: shouldUsePlugins, + shouldHideTitle: shouldHideTitle, + siteURL: siteURL, + siteApiRoot: siteApiRoot, + siteApiNamespace: siteApiNamespace, + namespaceExcludedPaths: namespaceExcludedPaths, + authHeader: authHeader, + webViewGlobals: webViewGlobals, + editorSettings: editorSettings, + locale: locale, + editorAssetsEndpoint: editorAssetsEndpoint, + cookies: cookies + ) + } +} + +public struct WebViewGlobal: Equatable { + let name: String + let value: WebViewGlobalValue + + public init(name: String, value: WebViewGlobalValue) throws { + // Validate name is a valid JavaScript identifier + guard Self.isValidJavaScriptIdentifier(name) else { + throw WebViewGlobalError.invalidIdentifier(name) + } + self.name = name + self.value = value + } + + private static func isValidJavaScriptIdentifier(_ name: String) -> Bool { + // Add validation logic for JavaScript identifiers + return name.range(of: "^[a-zA-Z_$][a-zA-Z0-9_$]*$", options: .regularExpression) != nil + } +} + +public enum WebViewGlobalError: Error { + case invalidIdentifier(String) +} + +public enum WebViewGlobalValue: Equatable { + case string(String) + case number(Double) + case boolean(Bool) + case object([String: WebViewGlobalValue]) + case array([WebViewGlobalValue]) + case null + + func toJavaScript() -> String { + switch self { + case .string(let str): + return "\"\(str.escaped)\"" + case .number(let num): + return "\(num)" + case .boolean(let bool): + return "\(bool)" + case .object(let dict): + let sortedKeys = dict.keys.sorted() + var pairs: [String] = [] + for key in sortedKeys { + let value = dict[key]! + pairs.append("\"\(key.escaped)\": \(value.toJavaScript())") + } + return "{\(pairs.joined(separator: ","))}" + case .array(let array): + return "[\(array.map { $0.toJavaScript() }.joined(separator: ","))]" + case .null: + return "null" + } + } +} + +public typealias EditorSettings = [String: Encodable] + +// String escaping extension +private extension String { + var escaped: String { + return self.replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + .replacingOccurrences(of: "\u{8}", with: "\\b") + .replacingOccurrences(of: "\u{12}", with: "\\f") + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 99c17876..f5ecca01 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -121,7 +121,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func loadEditor() { - if configuration.plugins { + if configuration.shouldUsePlugins { webView.configuration.userContentController.addScriptMessageHandler( EditorAssetsProvider(library: assetsLibrary), contentWorld: .page, @@ -143,37 +143,29 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func getEditorConfiguration() -> WKUserScript { - let escapedTitle = configuration.title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - let escapedContent = configuration.content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - - // Convert editor settings to JSON string if available - var editorSettingsJS = "undefined" - if let settings = configuration.editorSettings { - do { - let jsonData = try JSONSerialization.data(withJSONObject: settings, options: []) - if let jsonString = String(data: jsonData, encoding: .utf8) { - editorSettingsJS = jsonString - } - } catch { - NSLog("Failed to serialize editor settings: \(error)") - } - } + + // Generate JavaScript globals + let globalsJS = configuration.webViewGlobals.map { global in + "window[\"\(global.name)\"] = \(global.value.toJavaScript());" + }.joined(separator: "\n") let jsCode = """ + \(globalsJS) + window.GBKit = { siteURL: '\(configuration.siteURL)', siteApiRoot: '\(configuration.siteApiRoot)', siteApiNamespace: \(Array(configuration.siteApiNamespace)), namespaceExcludedPaths: \(Array(configuration.namespaceExcludedPaths)), authHeader: '\(configuration.authHeader)', - themeStyles: \(configuration.themeStyles), - hideTitle: \(configuration.hideTitle), - editorSettings: \(editorSettingsJS), + themeStyles: \(configuration.shouldUseThemeStyles), + hideTitle: \(configuration.shouldHideTitle), + editorSettings: \(configuration.editorSettingsJSON), locale: '\(configuration.locale)', post: { id: \(configuration.postID ?? -1), - title: '\(escapedTitle)', - content: '\(escapedContent)' + title: '\(configuration.escapedTitle)', + content: '\(configuration.escapedContent)' }, }; From 504677aa6b5204bd41c480e76e0cffa91c8b7333 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 30 May 2025 11:44:05 -0600 Subject: [PATCH 2/9] Add EditorConfigurationBuilderTests --- .../EditorConfigurationBuilderTests.swift | 163 ++++++++++++++++++ .../EditorConfigurationTests.swift | 115 ++++++++++++ .../GutenbergKitTests/GutenbergKitTests.swift | 8 - 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift create mode 100644 ios/Tests/GutenbergKitTests/EditorConfigurationTests.swift delete mode 100644 ios/Tests/GutenbergKitTests/GutenbergKitTests.swift diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift new file mode 100644 index 00000000..9a5fc9a1 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift @@ -0,0 +1,163 @@ +import Testing +@testable import GutenbergKit + +@Suite("Editor Configuration Builder Tests") +struct EditorConfigurationBuilderTests { + + @Test("Editor Configuration Defaults Are Correct") + func testThatEditorConfigurationBuilderDefaultsAreCorrect() throws { + let builder = EditorConfigurationBuilder().build() + #expect(builder.title == "") + #expect(builder.content == "") + #expect(builder.postID == nil) + #expect(builder.postType == nil) + #expect(builder.shouldUseThemeStyles == false) + #expect(builder.shouldUsePlugins == false) + #expect(builder.shouldHideTitle == false) + #expect(builder.siteURL == "") + #expect(builder.siteApiRoot == "") + #expect(builder.siteApiNamespace == []) + #expect(builder.namespaceExcludedPaths == []) + #expect(builder.authHeader == "") + #expect(builder.webViewGlobals == []) + #expect(builder.editorSettings.isEmpty) + #expect(builder.locale == "en") + } + + @Test("Editor Configuration to Builder") + func testThatEditorConfigurationToBuilder() throws { + let configuration = try EditorConfigurationBuilder() + .setTitle("Title") + .setContent("Content") + .setPostID(123) + .setPostType("Post") + .setShouldUseThemeStyles(true) + .setShouldUsePlugins(true) + .setShouldHideTitle(true) + .setSiteUrl("https://example.com") + .setSiteApiRoot("/wp-json") + .setSiteApiNamespace(["wp", "v2"]) + .setNamespaceExcludedPaths(["jetpack"]) + .setAuthHeader("Bearer Token") + .setWebViewGlobals([WebViewGlobal(name: "foo", value: .string("bar"))]) + .setEditorSettings(["foo":"bar"]) + .setLocale("fr") + .build() // Convert to a configuration + .toBuilder() // Then back to a builder (to test the configuration->builder logic) + .build() // Then back to a configuration to examine the results + + #expect(configuration.title == "Title") + #expect(configuration.content == "Content") + #expect(configuration.postID == 123) + #expect(configuration.postType == "Post") + #expect(configuration.shouldUseThemeStyles == true) + #expect(configuration.shouldUsePlugins == true) + #expect(configuration.shouldHideTitle == true) + #expect(configuration.siteURL == "https://example.com") + #expect(configuration.siteApiRoot == "/wp-json") + #expect(configuration.siteApiNamespace == ["wp", "v2"]) + #expect(configuration.namespaceExcludedPaths == ["jetpack"]) + #expect(configuration.authHeader == "Bearer Token") + #expect(configuration.webViewGlobals == [try WebViewGlobal(name: "foo", value: .string("bar"))]) + #expect(configuration.editorSettingsJSON == #"{"foo":"bar"}"#) + #expect(configuration.locale == "fr") + } + + @Test("Sets Title Correctly") + func editorConfigurationBuilderSetsTitleCorrectly() throws { + #expect(EditorConfigurationBuilder().setTitle("Title").build().title == "Title") + } + + @Test("Sets Content Correctly") + func editorConfigurationBuilderSetsContentCorrectly() throws { + #expect(EditorConfigurationBuilder().setContent("Content").build().content == "Content") + } + + @Test("Sets PostID Correctly") + func editorConfigurationBuilderSetsPostIDCorrectly() throws { + #expect(EditorConfigurationBuilder().setPostID(nil).build().postID == nil) + #expect(EditorConfigurationBuilder().setPostID(123).build().postID == 123) + } + + @Test("Sets Post Type Correctly") + func editorConfigurationBuilderSetsPostTypeCorrectly() throws { + #expect(EditorConfigurationBuilder().setPostType(nil).build().postType == nil) + #expect(EditorConfigurationBuilder().setPostType("post").build().postType == "post") + } + + @Test("Sets shouldUseThemeStyles Correctly") + func editorConfigurationBuilderSetsShouldUseThemeStylesCorrectly() throws { + #expect(EditorConfigurationBuilder().setShouldUseThemeStyles(true).build().shouldUseThemeStyles) + #expect(!EditorConfigurationBuilder().setShouldUseThemeStyles(false).build().shouldUseThemeStyles) + } + + @Test("Sets shouldUsePlugins Correctly") + func editorConfigurationBuilderSetsShouldUsePluginsCorrectly() throws { + #expect(EditorConfigurationBuilder().setShouldUsePlugins(true).build().shouldUsePlugins) + #expect(!EditorConfigurationBuilder().setShouldUsePlugins(false).build().shouldUsePlugins) + } + + @Test("Sets shouldHideTitle Correctly") + func editorConfigurationBuilderSetsShouldHideTitleCorrectly() throws { + #expect(EditorConfigurationBuilder().setShouldHideTitle(true).build().shouldHideTitle) + #expect(!EditorConfigurationBuilder().setShouldHideTitle(false).build().shouldHideTitle) + } + + @Test("Sets siteUrl Correctly") + func editorConfigurationBuilderSetsSiteUrlCorrectly() throws { + #expect(EditorConfigurationBuilder().setSiteUrl("https://example.com").build().siteURL == "https://example.com") + } + + @Test("Sets siteApiRoot Correctly") + func editorConfigurationBuilderSetsSiteApiRootCorrectly() throws { + #expect(EditorConfigurationBuilder().setSiteApiRoot("https://example.com/wp-json").build().siteApiRoot == "https://example.com/wp-json") + } + + @Test("Sets siteApiNamespace Correctly") + func editorConfigurationBuilderSetsApiNamespaceCorrectly() throws { + #expect(EditorConfigurationBuilder().setSiteApiNamespace(["wp/v2"]).build().siteApiNamespace == ["wp/v2"]) + } + + @Test("Sets namespaceExcludedPaths Correctly") + func editorConfigurationBuilderSetsNamespaceExcludedPathsCorrectly() throws { + #expect( + EditorConfigurationBuilder() + .setNamespaceExcludedPaths(["/wp-admin", "/wp-login.php"]) + .build() + .namespaceExcludedPaths + == ["/wp-admin", "/wp-login.php"] + ) + } + + @Test("Sets authHeader Correctly") + func editorConfigurationBuilderSetsAuthHeaderCorrectly() throws { + #expect(EditorConfigurationBuilder().setAuthHeader("Bearer token").build().authHeader == "Bearer token") + } + + @Test("Sets webViewGlobals Correctly") + func editorConfigurationBuilderSetsWebViewGlobalsCorrectly() throws { + #expect( + try EditorConfigurationBuilder() + .setWebViewGlobals([WebViewGlobal(name: "foo", value: .string("bar"))]) + .build() + .webViewGlobals + == [WebViewGlobal(name: "foo", value: .string("bar"))] + ) + } + + @Test("Sets editorSettings Correctly") + func editorConfigurationBuilderSetsEditorSettingsCorrectly() throws { + #expect( + EditorConfigurationBuilder() + .setEditorSettings(["foo": "bar"]) + .build() + .editorSettingsJSON + == "{\"foo\":\"bar\"}" + ) + } + + @Test("Sets locale Correctly") + func editorConfigurationBuilderSetsLocaleCorrectly() throws { + #expect(EditorConfigurationBuilder().setLocale("en").build().locale == "en") + } +} diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationTests.swift new file mode 100644 index 00000000..eb328fbf --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import GutenbergKit + +@Suite +final class EditorConfigurationTests { + + @Test + func testValidJavaScriptIdentifiers() throws { + let validIdentifiers = [ + "myVar", + "_privateVar", + "$jQuery", + "myVar123", + "MY_CONSTANT", + "a", + "A" + ] + + for identifier in validIdentifiers { + _ = try WebViewGlobal(name: identifier, value: .string("test")) + } + } + + @Test + func testInvalidJavaScriptIdentifiers() { + let invalidIdentifiers = [ + "123invalid", + "my-var", + "my.var", + "my var", + "", + "my@var", + "my#var" + ] + + for identifier in invalidIdentifiers { + #expect(throws: WebViewGlobalError.self, performing: { + try WebViewGlobal(name: identifier, value: .string("test")) + }) + } + } + + // MARK: - WebViewGlobalValue Tests + @Test + func testStringValueConversion() { + let testCases = [ + ("simple", "\"simple\""), + ("with \"quotes\"", "\"with \\\"quotes\\\"\""), + ("with\nnewline", "\"with\\nnewline\""), + ("with\ttab", "\"with\\ttab\""), + ("with\rreturn", "\"with\\rreturn\""), + ("with\u{8}backspace", "\"with\\bbackspace\""), + ("with\u{12}formfeed", "\"with\\fformfeed\"") + ] + + for (input, expected) in testCases { + #expect(WebViewGlobalValue.string(input).toJavaScript() == expected) + } + } + + @Test + func testNumberValueConversion() { + let testCases = [ + (42.0, "42.0"), + (-3.14, "-3.14"), + (0.0, "0.0"), + (1.0, "1.0") + ] + + for (input, expected) in testCases { + #expect(WebViewGlobalValue.number(input).toJavaScript() == expected) + } + } + + @Test + func testBooleanValueConversion() { + #expect(WebViewGlobalValue.boolean(true).toJavaScript() == "true") + #expect(WebViewGlobalValue.boolean(false).toJavaScript() == "false") + } + + @Test + func testNullValueConversion() { + #expect(WebViewGlobalValue.null.toJavaScript() == "null") + } + + @Test + func testObjectValueConversion() throws { + let object = WebViewGlobalValue.object([ + "name": .string("test"), + "count": .number(42), + "active": .boolean(true), + "nested": .object([ + "value": .string("nested") + ]) + ]) + + let actual = object.toJavaScript() + let expected = "{\"active\": true,\"count\": 42.0,\"name\": \"test\",\"nested\": {\"value\": \"nested\"}}" + #expect(actual == expected) + } + + @Test + func testArrayValueConversion() { + let array = WebViewGlobalValue.array([ + .string("test"), + .number(42), + .boolean(true), + .null + ]) + + let expected = "[\"test\",42.0,true,null]" + #expect(array.toJavaScript() == expected) + } +} diff --git a/ios/Tests/GutenbergKitTests/GutenbergKitTests.swift b/ios/Tests/GutenbergKitTests/GutenbergKitTests.swift deleted file mode 100644 index 7057df12..00000000 --- a/ios/Tests/GutenbergKitTests/GutenbergKitTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest -@testable import GutenbergKit - -final class GutenbergKitTests: XCTestCase { - func testExample() throws { - XCTAssert(true) - } -} From b9405e127b0789e7145102c89945ccc3f48ce23b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 30 May 2025 12:11:15 -0600 Subject: [PATCH 3/9] Make Test Suite run on macOS --- Makefile | 5 ++++- ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift | 2 ++ .../GutenbergKit/Sources/EditorViewController.swift | 7 ++++++- .../Sources/EditorViewControllerDelegate.swift | 2 ++ ios/Sources/GutenbergKit/Sources/GBWebView.swift | 2 ++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index abe232cd..6f064756 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,9 @@ lint-js: npm-dependencies test-js: npm-dependencies npm run test -- run +lint-swift: + swift package plugin swiftlint + local-android-library: build echo "--- :android: Building Library" ./android/gradlew -p ./android :gutenberg:publishToMavenLocal -exclude-task prepareToPublishToS3 @@ -59,7 +62,7 @@ build-swift-package: build $(call XCODEBUILD_CMD, build) test-swift-package: build - $(call XCODEBUILD_CMD, test) + swift test release: @echo "--- :rocket: Starting GutenbergKit Release Process" diff --git a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift b/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift index 9eb38249..0155fccc 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift @@ -1,5 +1,6 @@ import SwiftUI +#if canImport(UIKit) // TODO: Add search // TODO: Group these properly struct EditorBlockPicker: View { @@ -217,3 +218,4 @@ struct EditorBlockPickerSection: Identifiable { let name: String let blockTypes: [EditorBlockType] } +#endif diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index f5ecca01..e22b4dfe 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -1,9 +1,11 @@ -import UIKit @preconcurrency import WebKit import SwiftUI import Combine import CryptoKit +#if canImport(UIKit) +import UIKit + @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { public let webView: WKWebView @@ -415,3 +417,6 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W } } } + + +#endif diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 89b30704..c3fe9be7 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -1,5 +1,6 @@ import Foundation +#if canImport(UIKit) public protocol EditorViewControllerDelegate: AnyObject { /// Called when the editor loads. func editorDidLoad(_ viewContoller: EditorViewController) @@ -40,6 +41,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// - parameter type: The type of autocompleter that was triggered (e.g., "plus-symbol", "at-symbol"). func editor(_ viewController: EditorViewController, didTriggerAutocompleter type: String) } +#endif public struct EditorState { /// Set to `true` if the editor has non-empty content. diff --git a/ios/Sources/GutenbergKit/Sources/GBWebView.swift b/ios/Sources/GutenbergKit/Sources/GBWebView.swift index 2f13ae87..7c036fe8 100644 --- a/ios/Sources/GutenbergKit/Sources/GBWebView.swift +++ b/ios/Sources/GutenbergKit/Sources/GBWebView.swift @@ -2,9 +2,11 @@ import WebKit class GBWebView: WKWebView { + #if canImport(UIKit) /// Disables the default bottom bar that competes with the Gutenberg inserter /// override var inputAccessoryView: UIView? { nil } + #endif } From 51be5c295e7a6ee0e4c9a209a2dd2d44de8b08f1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 8 Jul 2025 20:21:30 -0400 Subject: [PATCH 4/9] docs: Document all editor configuration options --- .../Sources/EditorConfiguration.swift | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index f9c6b4cb..cebe7186 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -1,29 +1,38 @@ import Foundation public struct EditorConfiguration { - /// The initial title to initialize the editor with. + /// Initial title for populating the editor public let title: String - /// The initial content to initialize the editor with. + /// Initial content for populating the editor public let content: String + /// ID of the post being edited public let postID: Int? + /// Type of the post being edited public let postType: String? + /// Toggles application of theme styles public let shouldUseThemeStyles: Bool + /// Toggles loading plugin-provided editor assets public let shouldUsePlugins: Bool + /// Toggles visibility of the title field public let shouldHideTitle: Bool + /// Root URL for the site public let siteURL: String + /// Root URL for the site API public let siteApiRoot: String + /// Namespaces for the site API public let siteApiNamespace: [String] + /// Paths excluded from API namespacing public let namespaceExcludedPaths: [String] + /// Authorization header public let authHeader: String - + /// Global variables to be made available to the editor public let webViewGlobals: [WebViewGlobal] - /// Raw block editor settings from the WordPress REST API public let editorSettings: EditorSettings - - /// The locale to use for translations + /// Locale used for translations public let locale: String + /// Endpoint for loading editor assets, used when enabling `shouldUsePlugins` public var editorAssetsEndpoint: URL? // Cookies @@ -46,8 +55,7 @@ public struct EditorConfiguration { webViewGlobals: [WebViewGlobal], editorSettings: EditorSettings, locale: String, - editorAssetsEndpoint: URL? = nil, - cookies: [HTTPCookie] = [] + editorAssetsEndpoint: URL? = nil ) { self.title = title self.content = content @@ -65,7 +73,6 @@ public struct EditorConfiguration { self.editorSettings = editorSettings self.locale = locale self.editorAssetsEndpoint = editorAssetsEndpoint - self.cookies = cookies } public func toBuilder() -> EditorConfigurationBuilder { @@ -85,8 +92,7 @@ public struct EditorConfiguration { webViewGlobals: webViewGlobals, editorSettings: editorSettings, locale: locale, - editorAssetsEndpoint: editorAssetsEndpoint, - cookies: cookies + editorAssetsEndpoint: editorAssetsEndpoint ) } @@ -124,7 +130,6 @@ public struct EditorConfigurationBuilder { private var editorSettings: EditorSettings private var locale: String private var editorAssetsEndpoint: URL? - private var cookies: [HTTPCookie] public init( title: String = "", @@ -142,8 +147,7 @@ public struct EditorConfigurationBuilder { webViewGlobals: [WebViewGlobal] = [], editorSettings: EditorSettings = [:], locale: String = "en", - editorAssetsEndpoint: URL? = nil, - cookies: [HTTPCookie] = [] + editorAssetsEndpoint: URL? = nil ){ self.title = title self.content = content @@ -161,7 +165,6 @@ public struct EditorConfigurationBuilder { self.editorSettings = editorSettings self.locale = locale self.editorAssetsEndpoint = editorAssetsEndpoint - self.cookies = cookies } public func setTitle(_ title: String) -> EditorConfigurationBuilder { @@ -260,12 +263,6 @@ public struct EditorConfigurationBuilder { return copy } - public func setCookies(_ cookies: [HTTPCookie]) -> EditorConfigurationBuilder { - var copy = self - copy.cookies = cookies - return copy - } - public func build() -> EditorConfiguration { EditorConfiguration( title: title, @@ -283,8 +280,7 @@ public struct EditorConfigurationBuilder { webViewGlobals: webViewGlobals, editorSettings: editorSettings, locale: locale, - editorAssetsEndpoint: editorAssetsEndpoint, - cookies: cookies + editorAssetsEndpoint: editorAssetsEndpoint ) } } From 68edba4da2e4f95e8d0d609d1ff58f397693fdf3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 8 Jul 2025 21:20:53 -0400 Subject: [PATCH 5/9] test: Expand builder tests --- .../EditorConfigurationBuilderTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift index 9a5fc9a1..18d595e1 100644 --- a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import GutenbergKit @@ -22,6 +23,8 @@ struct EditorConfigurationBuilderTests { #expect(builder.webViewGlobals == []) #expect(builder.editorSettings.isEmpty) #expect(builder.locale == "en") + #expect(builder.editorAssetsEndpoint == nil) + #expect(builder.cookies == []) } @Test("Editor Configuration to Builder") @@ -42,6 +45,8 @@ struct EditorConfigurationBuilderTests { .setWebViewGlobals([WebViewGlobal(name: "foo", value: .string("bar"))]) .setEditorSettings(["foo":"bar"]) .setLocale("fr") + .setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) + .setCookies([HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) .build() // Convert to a configuration .toBuilder() // Then back to a builder (to test the configuration->builder logic) .build() // Then back to a configuration to examine the results @@ -61,6 +66,8 @@ struct EditorConfigurationBuilderTests { #expect(configuration.webViewGlobals == [try WebViewGlobal(name: "foo", value: .string("bar"))]) #expect(configuration.editorSettingsJSON == #"{"foo":"bar"}"#) #expect(configuration.locale == "fr") + #expect(configuration.editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) + #expect(configuration.cookies == [HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) } @Test("Sets Title Correctly") @@ -160,4 +167,14 @@ struct EditorConfigurationBuilderTests { func editorConfigurationBuilderSetsLocaleCorrectly() throws { #expect(EditorConfigurationBuilder().setLocale("en").build().locale == "en") } + + @Test("Sets editorAssetsEndpoint Correctly") + func editorConfigurationBuilderSetsEditorAssetsEndpointCorrectly() throws { + #expect(EditorConfigurationBuilder().setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")).build().editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) + } + + @Test("Sets cookies Correctly") + func editorConfigurationBuilderSetsCookiesCorrectly() throws { + #expect(EditorConfigurationBuilder().setCookies([HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]).build().cookies == [HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) + } } From 6b13dfd35740a48a18adcbd7ce89527f76ebd3cd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 9 Jul 2025 07:12:05 -0400 Subject: [PATCH 6/9] test: Fix EditorManifestTests for macOS runs Apply iOS conditionals used in other, existing code. --- .../Sources/Cache/EditorAssetsLibrary.swift | 13 +++++++++++++ .../GutenbergKitTests/EditorManifestTests.swift | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift index f44637b1..278248da 100644 --- a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift @@ -2,6 +2,9 @@ import Foundation import CryptoKit import SwiftSoup +#if canImport(UIKit) +import UIKit + public actor EditorAssetsLibrary { enum ManifestError: Error { case unavailable @@ -191,6 +194,8 @@ private extension String { } } +#endif + struct EditorAssetsMainifest: Codable { var scripts: String var styles: String @@ -245,7 +250,11 @@ struct EditorAssetsMainifest: Codable { 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) } } @@ -268,7 +277,11 @@ struct EditorAssetsMainifest: Codable { 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) } } diff --git a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift index 0dfc1402..5a74014d 100644 --- a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift @@ -30,9 +30,15 @@ struct EditorManifestTests { #expect(link.hasPrefix("http://")) } + #if canImport(UIKit) for link in try forEditor.parseAssetLinks(defaultScheme: nil) { #expect(link.hasPrefix("gbk-cache-http://")) } + #else + for link in try forEditor.parseAssetLinks(defaultScheme: nil) { + #expect(link.hasPrefix("http://")) + } + #endif } @Test From 7f0937a05174385c3451cb1c7c11beb06607d209 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 9 Jul 2025 07:16:55 -0400 Subject: [PATCH 7/9] ci: Revert Swift test script device configuration removal This was changed while making the tests pass when running on macOS, but it is unclear why the original CI script was changed. Presumably we should run the tests with a fixed device on the CI. https://github.com/wordpress-mobile/GutenbergKit/pull/146/commits/debc1c74d5d460ac59363edf5117026e1f3ffe01#diff-76ed074a9305c04054cdebb9e9aad2d818052b07091de1f20cad0bbac34ffb52L62 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6f064756..d76d4cfa 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ build-swift-package: build $(call XCODEBUILD_CMD, build) test-swift-package: build - swift test + $(call XCODEBUILD_CMD, test) release: @echo "--- :rocket: Starting GutenbergKit Release Process" From cb325d6008833f398e4218eea246300d39476495 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 5 Aug 2025 09:08:47 -0400 Subject: [PATCH 8/9] test: Remove builder cookie assertions This functionality was removed in the trunk branch. --- .../EditorConfigurationBuilderTests.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift index 18d595e1..7dd3c071 100644 --- a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift @@ -24,7 +24,6 @@ struct EditorConfigurationBuilderTests { #expect(builder.editorSettings.isEmpty) #expect(builder.locale == "en") #expect(builder.editorAssetsEndpoint == nil) - #expect(builder.cookies == []) } @Test("Editor Configuration to Builder") @@ -46,7 +45,6 @@ struct EditorConfigurationBuilderTests { .setEditorSettings(["foo":"bar"]) .setLocale("fr") .setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) - .setCookies([HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) .build() // Convert to a configuration .toBuilder() // Then back to a builder (to test the configuration->builder logic) .build() // Then back to a configuration to examine the results @@ -67,7 +65,6 @@ struct EditorConfigurationBuilderTests { #expect(configuration.editorSettingsJSON == #"{"foo":"bar"}"#) #expect(configuration.locale == "fr") #expect(configuration.editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) - #expect(configuration.cookies == [HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) } @Test("Sets Title Correctly") @@ -172,9 +169,4 @@ struct EditorConfigurationBuilderTests { func editorConfigurationBuilderSetsEditorAssetsEndpointCorrectly() throws { #expect(EditorConfigurationBuilder().setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")).build().editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) } - - @Test("Sets cookies Correctly") - func editorConfigurationBuilderSetsCookiesCorrectly() throws { - #expect(EditorConfigurationBuilder().setCookies([HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]).build().cookies == [HTTPCookie(properties: [.name: "foo", .value: "bar", .domain: "example.com", .path: "/"])!]) - } } From fc28a0ab89d20d0e1c6c1e37e2beeb45515d73ad Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:45:57 -0600 Subject: [PATCH 9/9] Fix cookies --- .../GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift | 5 ----- ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift index 278248da..9410320b 100644 --- a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift @@ -2,9 +2,6 @@ import Foundation import CryptoKit import SwiftSoup -#if canImport(UIKit) -import UIKit - public actor EditorAssetsLibrary { enum ManifestError: Error { case unavailable @@ -194,8 +191,6 @@ private extension String { } } -#endif - struct EditorAssetsMainifest: Codable { var scripts: String var styles: String diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index cebe7186..d66301c3 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -55,7 +55,8 @@ public struct EditorConfiguration { webViewGlobals: [WebViewGlobal], editorSettings: EditorSettings, locale: String, - editorAssetsEndpoint: URL? = nil + editorAssetsEndpoint: URL? = nil, + cookies: [HTTPCookie] = [] ) { self.title = title self.content = content @@ -73,6 +74,7 @@ public struct EditorConfiguration { self.editorSettings = editorSettings self.locale = locale self.editorAssetsEndpoint = editorAssetsEndpoint + self.cookies = cookies } public func toBuilder() -> EditorConfigurationBuilder {