diff --git a/Package.swift b/Package.swift index 233d7ba..988c347 100644 --- a/Package.swift +++ b/Package.swift @@ -8,8 +8,6 @@ let package = Package( platforms: [ .macOS("14"), .iOS("17"), - .tvOS(.v16), - .watchOS("10") ], products: [ // Executable can't share the same name as the library, or we get compiler errors due to conflicts of the two products. @@ -27,9 +25,15 @@ let package = Package( .target(name: "Slipstream", dependencies: [ "SwiftSoup", + "TypeIntrospection", ]), .testTarget(name: "SlipstreamTests", dependencies: [ "Slipstream", - ]) + ]), + + .target(name: "TypeIntrospection"), + .testTarget(name: "TypeIntrospectionTests", dependencies: [ + "TypeIntrospection", + ]), ] ) diff --git a/Sources/Slipstream/Documentation.docc/Architecture/HowEnvironmentWorks.md b/Sources/Slipstream/Documentation.docc/Architecture/HowEnvironmentWorks.md new file mode 100644 index 0000000..e69de29 diff --git a/Sources/Slipstream/Documentation.docc/Architecture/HowSlipstreamWorks.md b/Sources/Slipstream/Documentation.docc/Architecture/HowSlipstreamWorks.md new file mode 100644 index 0000000..752b211 --- /dev/null +++ b/Sources/Slipstream/Documentation.docc/Architecture/HowSlipstreamWorks.md @@ -0,0 +1,79 @@ +# How Slipstream works + +A from the ground up explanation of Slipstream's architecture. + +Slipstream is designed to offer a SwiftUI-like approach to building HTML documents +that are compatible with [Tailwind CSS](http://tailwindcss.com). + +## Core Concepts + +### Result Builders + +Slipstream uses Swift [result builders](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0289-result-builders.md) +to enable the construction of HTML documents in a syntax similar to SwiftUI's. +Result builders encourage defining structural data in a hierarchical and declarative +manner, separating intent (what the site should look like) from implementation (how +it's turned into HTML). This separation of concerns allows you to focus on the +design and structure of your web pages without getting bogged down by the +intricacies of HTML generation. + +### View Protocol + +The primary type that Slipstream's result builders work with is the ``View`` protocol. +Like SwiftUI, a View represents a part of an HTML document that can be combined +with other View instances to create a website. The protocol defines a contract +that all views must adhere to, ensuring consistency in how views are constructed and +rendered as HTML. + +```swift +public protocol View { + associatedtype Content: View + @ViewBuilder var body: Self.Content { get } + + func render(_ container: Element) throws +} +``` + +The ``View/body`` property returns the content of the view, and in most cases, is +the only part of the View protocol that you need to implement. The ``ViewBuilder`` +attribute is what enables our use of the SwiftUI-like syntax in the body implementation. + +The ``View/render(_:environment:)`` method, on the other hand, is responsible for converting the +view’s content into HTML elements. You'll only need to implement this method if you +need to generate new types of HTML. + +### W3C HTML Views + +Slipstream provides a catalog of standard [W3C HTML](https://html.spec.whatwg.org/multipage/) +View implementations that can be used to build your website. Read to +learn more about the different Views available in Slipstream. + +### Rendering a View as HTML + +The combination of result builders, the View protocol, and a ``Text`` view is all we +need to build a simple "Hello, world!" example: + +```swift +struct HelloWorld: View { + var body: some View { + Text("Hello, world!") + } +} + +print(try renderHTML(HelloWorld())) +``` + +In this example, the Text view is treated as a single "block" in HelloWorld's body. + +Slipstream depends on [SwiftSoup](https://scinfu.github.io/SwiftSoup/) for rendering valid +HTML. Each call to ``renderHTML(_:)`` follows the same rough flow: + +1. The ``renderHTML(_:)`` method creates a SwiftSoup `Document` object. +2. This object is then passed to HelloWorld's ``View/render(_:environment:)`` method, + which in turn calls Text's `render(_:environment:)` method, which appends the string + to the document. This step happens recursively until the entire view + hierarchy has had a chance to render its contents into the document. +3. The Document, at this point an in-memory Document Object Model (DOM) representation, + is then rendered as HTML using SwiftSoup and returned. + +You can then save the resulting html string to the appropriate file. diff --git a/Sources/Slipstream/Documentation.docc/DataAndStorage/Environment.md b/Sources/Slipstream/Documentation.docc/DataAndStorage/Environment.md new file mode 100644 index 0000000..4b7c45f --- /dev/null +++ b/Sources/Slipstream/Documentation.docc/DataAndStorage/Environment.md @@ -0,0 +1,7 @@ +# ``Environment`` + +## Topics + +### Getting the value + +- ``Environment/wrappedValue`` diff --git a/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValues.md b/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValues.md new file mode 100644 index 0000000..d082eb5 --- /dev/null +++ b/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValues.md @@ -0,0 +1,7 @@ +# ``EnvironmentValues`` + +## Topics + +### Creating and accessing values + +- ``EnvironmentValues/init()`` diff --git a/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValuesSection.md b/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValuesSection.md new file mode 100644 index 0000000..aa6e3b5 --- /dev/null +++ b/Sources/Slipstream/Documentation.docc/DataAndStorage/EnvironmentValuesSection.md @@ -0,0 +1,84 @@ +# Environment values + +Share data throughout a view hierarchy using the environment. + +Like SwiftUI, views in Slipstream can react to configuration information +that they read from the environment using an ``Environment`` property wrapper. + +A view inherits its environment from its container view, subject to explicit +changes from an ``View/environment(_:_:)`` view modifier. As a result, you +can configure an entire hierarchy of views by modifying the environment of +the group’s container. + +## Defining custom environment values + +To create a custom environment value, you first define a type that conforms +to the ``EnvironmentKey`` protocol. This type will be used to uniquely +identify the value in the environment. + +```swift +struct PathEnvironmentKey: EnvironmentKey { + static let defaultValue: String = "/" +} +``` + +You must then provide a way to read and write the environment value: + +```swift +extension EnvironmentValues { + var path: String { + get { self[PathEnvironmentKey.self] } + set { self[PathEnvironmentKey.self] = newValue } + } +} +``` + +While not required, it's also a good practice to provide a ``View`` +extension that modifies the environment value: + +```swift +extension View { + func path(_ path: String) -> some View { + environment(\.path, path) + } +} +``` + +### How to read environment properties + +In any ``View``, you can read an environment value using the ``Environment`` +property wrapper: + +```swift +struct MyView: View { + @Environment(\.path) var path + + // ... +} +``` + +You can then read the environment property like any other property on +the view. When the view is rendered, the value of the property will +reflect the environment this view is being rendered within. + +### How to change environment properties + +Within the ``View/body`` of any view you can use the ``View/environment(_:_:)`` +modifier to set the environment value for that view and its descendants. + +```swift +MyView() + .environment(\.path, "/home") +``` + + +## Topics + +### Accessing environment values + +- ``Environment`` +- ``EnvironmentValues`` + +### Creating custom environment values + +- ``EnvironmentKey`` diff --git a/Sources/Slipstream/Documentation.docc/Slipstream.md b/Sources/Slipstream/Documentation.docc/Slipstream.md index 2b2e786..89d5e8f 100644 --- a/Sources/Slipstream/Documentation.docc/Slipstream.md +++ b/Sources/Slipstream/Documentation.docc/Slipstream.md @@ -50,16 +50,19 @@ print(try renderHTML(HelloWorld())) ## Topics +### Architecture + +- + +### Data and storage + +- + ### Views -- - - -### Rendering Views +### Rendering views - ``renderHTML(_:)`` - -### Architecture - -- diff --git a/Sources/Slipstream/Documentation.docc/Views/Architecture/HowSlipstreamWorks.md b/Sources/Slipstream/Documentation.docc/Views/Architecture/HowSlipstreamWorks.md index 2b59ffa..86be7ab 100644 --- a/Sources/Slipstream/Documentation.docc/Views/Architecture/HowSlipstreamWorks.md +++ b/Sources/Slipstream/Documentation.docc/Views/Architecture/HowSlipstreamWorks.md @@ -38,7 +38,7 @@ The ``View/body`` property returns the content of the view, and in most cases, i the only part of the View protocol that you need to implement. The ``ViewBuilder`` attribute is what enables our use of the SwiftUI-like syntax in the body implementation. -The ``View/render(_:)`` method, on the other hand, is responsible for converting the +The ``View/render(_:environment:)`` method, on the other hand, is responsible for converting the view’s content into HTML elements. You'll only need to implement this method if you need to generate new types of HTML. @@ -66,6 +66,5 @@ print(try renderHTML(HelloWorld())) In this example, the Text view is treated as a single "block" in HelloWorld's body. The ``renderHTML(_:)`` method creates a SwiftSoup Document object and then passes this -document to the HelloWorld's ``View/render(_:)`` method, which in turn calls Text's -`render(_:)` method, which appends the string to the Document object. - +document to the HelloWorld's ``View/render(_:environment:)`` method, which in turn calls Text's +`render(_:environment:)` method, which appends the string to the Document object. diff --git a/Sources/Slipstream/Documentation.docc/Views/Fundamentals/Fundamentals.md b/Sources/Slipstream/Documentation.docc/Views/Fundamentals/Fundamentals.md index b766cbf..01f3096 100644 --- a/Sources/Slipstream/Documentation.docc/Views/Fundamentals/Fundamentals.md +++ b/Sources/Slipstream/Documentation.docc/Views/Fundamentals/Fundamentals.md @@ -9,7 +9,11 @@ conforms to the ``View`` protocol can act as a view in your website. ## Topics -### Creating a View +### Creating a view - ``View`` - ``ViewBuilder`` + +### Modifying a view + +- ``ViewModifier`` diff --git a/Sources/Slipstream/Documentation.docc/Views/Fundamentals/View.md b/Sources/Slipstream/Documentation.docc/Views/Fundamentals/View.md index 90db89d..2a3b19b 100644 --- a/Sources/Slipstream/Documentation.docc/Views/Fundamentals/View.md +++ b/Sources/Slipstream/Documentation.docc/Views/Fundamentals/View.md @@ -6,7 +6,12 @@ - ``View/body`` - ``View/Content`` +- ``View/modifier(_:)`` ### Implementing HTML rendering -- ``View/render(_:)`` +- ``View/render(_:environment:)`` + +### State modifiers + +- ``View/environment(_:_:)`` diff --git a/Sources/Slipstream/Rendering/Render.swift b/Sources/Slipstream/Rendering/Render.swift index 412ca9a..ac3f8b5 100644 --- a/Sources/Slipstream/Rendering/Render.swift +++ b/Sources/Slipstream/Rendering/Render.swift @@ -25,6 +25,6 @@ import SwiftSoup /// - Returns: The generated and formatted HTML string. public func renderHTML(_ view: any View) throws -> String { let document = Document("/") - try view.render(document) + try view.render(document, environment: EnvironmentValues()) return try document.html() } diff --git a/Sources/Slipstream/Views/Fundamentals/DataAndStorage/Environment.swift b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/Environment.swift new file mode 100644 index 0000000..475401c --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/Environment.swift @@ -0,0 +1,58 @@ +/// A property wrapper that reads a value from a view's environment. +/// +/// Use the `Environment` property wrapper to read a value stored in a view's +/// environment. Indicate the value to read using an ``EnvironmentValues`` +/// key path in the property declaration. +/// +/// You can condition a view's content on the associated value, which +/// you read from the declared property's ``wrappedValue``. As with any property +/// wrapper, you access the wrapped value by directly referring to the property: +/// +/// You can use this property wrapper to read --- but not set --- an environment +/// value. You can override existing environment values, as well as set custom +/// environment values that you define, using the ``View/environment(_:_:)`` +/// view modifier. +@available(iOS 17.0, macOS 14.0, *) +@propertyWrapper +public struct Environment { + /// Creates an environment property to read the specified key path. + /// + /// Don’t call this initializer directly. Instead, declare a property + /// with the ``Environment`` property wrapper, and provide the key path of + /// the environment value that the property should reflect: + /// + /// ```swift + /// struct MyView: View { + /// @Environment(\.path) var path + /// + /// // ... + /// } + /// ``` + /// + /// You can't modify the environment value using a property like this. Instead, + /// use the ``View/environment(_:_:)`` view modifier on a view to set + /// a value for a view hierarchy. + /// + /// - Parameter keyPath: A key path to a specific resulting value. + public init(_ keyPath: KeyPath) { + self.keyPath = keyPath + } + + /// The current value of the environment property. + /// + /// The wrapped value property provides primary access to the value's data. + /// However, you don't access `wrappedValue` directly. Instead, you read the + /// property variable created with the ``Environment`` property wrapper. + public var wrappedValue: Value { + get { environmentValues[keyPath: keyPath] } + set { fatalError("Wrapped value should not be used.") } + } + + /// The environment storage, from which the value of the property will be retrieved. + /// + /// When a view is rendered, this property will be modified with the contextual environment values. + private var environmentValues: EnvironmentValues = EnvironmentValues() + + /// The key path to the property represented by this Environment wrapper. + private let keyPath: KeyPath +} diff --git a/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentKey.swift b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentKey.swift new file mode 100644 index 0000000..05fe2c0 --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentKey.swift @@ -0,0 +1,75 @@ +/// A key for accessing values in the environment. +/// +/// You can create custom environment values by extending the +/// ``EnvironmentValues`` structure with new properties. +/// First declare a new environment key type and specify a value for the +/// required ``defaultValue`` property: +/// +/// ```swift +/// private struct MyEnvironmentKey: EnvironmentKey { +/// static let defaultValue: String = "Default value" +/// } +/// ``` +/// +/// The Swift compiler automatically infers the associated ``Value`` type as the +/// type you specify for the default value. Then use the key to define a new +/// environment value property: +/// +/// ```swift +/// extension EnvironmentValues { +/// var myCustomValue: String { +/// get { self[MyEnvironmentKey.self] } +/// set { self[MyEnvironmentKey.self] = newValue } +/// } +/// } +/// ``` +/// +/// Clients of your environment value never use the key directly. +/// Instead, they use the key path of your custom environment value property. +/// To set the environment value for a view and all its subviews, add the +/// ``View/environment(_:_:)`` view modifier to that view: +/// +/// ```swift +/// MyView() +/// .environment(\.myCustomValue, "Another string") +/// ``` +/// +/// As a convenience, you can also define a dedicated view modifier to +/// apply this environment value: +/// +/// ```swift +/// extension View { +/// func myCustomValue(_ myCustomValue: String) -> some View { +/// environment(\.myCustomValue, myCustomValue) +/// } +/// } +/// ``` +/// +/// This improves clarity at the call site: +/// +/// ```swift +/// MyView() +/// .myCustomValue("Another string") +/// ``` +/// +/// To read the value from inside `MyView` or one of its descendants, use the +/// ``Environment`` property wrapper: +/// +/// ```swift +/// struct MyView: View { +/// @Environment(\.myCustomValue) var customValue: String +/// +/// var body: some View { +/// Text(customValue) // Displays "Another string". +/// } +/// } +/// ``` +@available(iOS 17.0, macOS 14.0, *) +public protocol EnvironmentKey: Hashable { + /// The associated type representing the type of the environment key's + /// value. + associatedtype Value + + /// The default value for the environment key. + static var defaultValue: Self.Value { get } +} diff --git a/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentValues.swift b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentValues.swift new file mode 100644 index 0000000..31a760b --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/DataAndStorage/EnvironmentValues.swift @@ -0,0 +1,53 @@ +/// A collection of environment values propagated through a view hierarchy. +/// +/// Like SwiftUI, Slipstream provides a mutable collection of values that your +/// website's views can access as needed. +/// +/// You will rarely interact directly with the `EnvironmentValues` structure. +/// +/// Instead, to read a value from the structure, declare a property using the +/// ``Environment`` property wrapper and specify the value's key path like so: +/// +/// ```swift +/// struct MyView: View { +/// @Environment(\.path) var path +/// +/// // ... +/// } +/// ``` +/// +/// You can set or override some values using the ``View/environment(_:_:)`` +/// view modifier like so: +/// +/// ```swift +/// MyView() +/// .environment(\.path, "/home") +/// ``` +/// +/// The value that you set affects the environment for the view that you modify +/// — including its descendants in the view hierarchy — but only up to the +/// point where you apply a different environment modifier. +/// +/// Learn how to create custom environment values in . +@available(iOS 17.0, macOS 14.0, *) +public struct EnvironmentValues { + /// Creates an environment values instance. + /// + /// You don't typically create an instance of ``EnvironmentValues`` + /// directly. Doing so would provide access only to default values that + /// don't update based on context. + /// + /// Instead, you rely on an environment values' instance + /// that Slipstream manages for you when you use the ``Environment`` + /// property wrapper and the ``View/environment(_:_:)`` view modifier. + public init() { + } + + /// Accesses the environment value associated with a custom key. + public subscript(key: K.Type) -> K.Value where K: EnvironmentKey { + get { return storage[ObjectIdentifier(K.self)] as? K.Value ?? K.defaultValue } + set { storage[ObjectIdentifier(K.self)] = newValue } + } + + private var storage: [ObjectIdentifier: Any] = [:] +} diff --git a/Sources/Slipstream/Views/Fundamentals/View+environment.swift b/Sources/Slipstream/Views/Fundamentals/View+environment.swift new file mode 100644 index 0000000..df7f5b1 --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/View+environment.swift @@ -0,0 +1,56 @@ +import SwiftSoup + +extension View { + /// Sets the environment value of the specified key path to the given value. + /// + /// Use this modifier to set one of the writable properties of the + /// ``EnvironmentValues`` structure, including custom values that you + /// create. + /// + /// Prefer dedicated modifiers when available, and offer your own when + /// defining custom environment values. + /// + /// This modifier affects the given view, as well as that view's descendant + /// views. It has no effect outside the view hierarchy on which you call it. + /// + /// - Parameters: + /// - keyPath: A key path that indicates the property of the + /// ``EnvironmentValues`` structure to update. + /// - value: The new value to set for the item specified by `keyPath`. + /// + /// - Returns: A view that has the given value set in its environment. + public func environment( + _ keyPath: WritableKeyPath, + _ value: V + ) -> some View { + return self.modifier(EnvironmentModifier(keyPath: keyPath, value: value)) + } +} + +/// A view modifier that will inject the given environment value assigned to the given keyPath when rendered. +private struct EnvironmentModifier: ViewModifier { + let keyPath: WritableKeyPath + let value: Value + + @ViewBuilder + public func body(content: T) -> some View { + EnvironmentModifierView(keyPath: keyPath, value: value) { + content + } + } +} + +/// Injects an environment value into the environment when the view is rendered. +private struct EnvironmentModifierView: View { + typealias Body = Never + + let keyPath: WritableKeyPath + let value: Value + let content: () -> Content + + func render(_ container: Element, environment: EnvironmentValues) throws { + var environment = environment + environment[keyPath: keyPath] = value + try self.content().render(container, environment: environment) + } +} diff --git a/Sources/Slipstream/Views/Fundamentals/View+modifier.swift b/Sources/Slipstream/Views/Fundamentals/View+modifier.swift new file mode 100644 index 0000000..f3eabc4 --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/View+modifier.swift @@ -0,0 +1,40 @@ +extension View { + /// Applies a modifier to a view and returns a new view. + /// + /// Use this modifier to combine a ``View`` and a ``ViewModifier``, to + /// create a new view. For example, if you create a view modifier for + /// a new kind of caption with blue text surrounded by a rounded rectangle: + /// + /// ```swift + /// struct TitleText: ViewModifier { + /// func body(content: Content) -> some View { + /// content + /// .font(.extraLarge) + /// .bold() + /// } + /// } + /// ``` + /// + /// You can use ``modifier(_:)`` to extend ``View`` to create new modifier + /// for applying the `TitleText` defined above: + /// + /// ```swift + /// extension View { + /// func titleText() -> some View { + /// modifier(TitleText()) + /// } + /// } + /// ``` + /// + /// Then you can apply title text to any view: + /// + /// ```swift + /// Text("Slipstream") + /// .titleText() + /// ``` + /// + /// - Parameter modifier: The modifier to apply to this view. + public func modifier(_ modifier: M) -> M.Body where M: ViewModifier, M.Content == Self { + return modifier.body(content: self) + } +} diff --git a/Sources/Slipstream/Views/Fundamentals/View.swift b/Sources/Slipstream/Views/Fundamentals/View.swift index f74b9e4..1d60e6c 100644 --- a/Sources/Slipstream/Views/Fundamentals/View.swift +++ b/Sources/Slipstream/Views/Fundamentals/View.swift @@ -1,4 +1,5 @@ import SwiftSoup +import TypeIntrospection /// A type that represents part of your HTML document. /// @@ -17,6 +18,7 @@ import SwiftSoup /// Assemble the view's body by combining one or more of the built-in views /// provided by Slipstream, like the ``Text`` instance in the example above, plus /// other custom views that you define, into a hierarchy of views. +@available(iOS 17.0, macOS 14.0, *) public protocol View { /// The type of view representing the content of this view. /// @@ -49,14 +51,59 @@ public protocol View { /// /// If this method is not implemented, a default implementation will be /// provided that recurses the render calls on `body`. - func render(_ container: Element) throws + func render(_ container: Element, environment: EnvironmentValues) throws } extension View { + /// Returns a copy of self with the given environment values injected into any @Environment properties defined by the receiver. + private func injectEnvironment(environment: EnvironmentValues) throws -> Self { + /// By default, `@Environment` properties have "empty" storage. + /// Our goal is to return a copy of this view with the environment properties' storage filled + /// with the current environment values. + var copy = self + + /// To enumerate all of the `@Environment` properties on this view, we rely on introspection of this view's type. + let introspectionOfSelf = TypeIntrospection(type: type(of: self).self) + for viewProperty in introspectionOfSelf.properties { + /// `@Environment` properties are mutable and have internal `EnvironmentValues` storage. + guard viewProperty.isVar, + let environmentValuesProperty = viewProperty.introspection.properties.first(where: { $0.introspection.type is EnvironmentValues.Type }) else { + continue + } + /// To modify the property, we first get the `@Environment` property as an opaque value type. + var value = try viewProperty.getValue(in: self) + /// We then use the property abstraction to store our contextual environment on the value (some `@Environment` type). + try environmentValuesProperty.setValue(to: environment, in: &value) + /// And lastly, we replace our view's `@Environment` property with the mutated instance. + try viewProperty.setValue(to: value, in: ©) + + /// Assume we have a View defined like this: + /// + /// ``` + /// struct HeaderLink: View { + /// @Environment(\.path) var path + /// @Environment(\.weight) var weight + /// } + /// + /// let link = HeaderLink() + /// ``` + /// + /// Then in effect, the code above accomplishes the following: + /// + /// ``` + /// var copy = link + /// copy.path.environmentValues = environment + /// copy.weight.environmentValues = environment + /// return copy + /// ``` + } + return copy + } + /// This default implementation recurses the render call on `body`'s contents /// and is sufficient for most custom `View`-conforming types. - public func render(_ container: Element) throws { - try body.render(container) + public func render(_ container: Element, environment: EnvironmentValues) throws { + try injectEnvironment(environment: environment).body.render(container, environment: environment) } } diff --git a/Sources/Slipstream/Views/Fundamentals/ViewBuilder.swift b/Sources/Slipstream/Views/Fundamentals/ViewBuilder.swift index f3423d1..d20be08 100644 --- a/Sources/Slipstream/Views/Fundamentals/ViewBuilder.swift +++ b/Sources/Slipstream/Views/Fundamentals/ViewBuilder.swift @@ -3,6 +3,7 @@ /// You typically use ``ViewBuilder`` as a parameter attribute for child /// view-producing closure parameters, allowing those closures to provide /// multiple child views. +@available(iOS 17.0, macOS 14.0, *) @resultBuilder public struct ViewBuilder { /// Passes a single view written as a child view through unmodified. diff --git a/Sources/Slipstream/Views/Fundamentals/ViewModifier.swift b/Sources/Slipstream/Views/Fundamentals/ViewModifier.swift new file mode 100644 index 0000000..236f2fb --- /dev/null +++ b/Sources/Slipstream/Views/Fundamentals/ViewModifier.swift @@ -0,0 +1,39 @@ +/// A modifier that you apply to a view or another view modifier, producing a +/// different version of the original value. +/// +/// Adopt the ``ViewModifier`` protocol when you want to create a reusable +/// modifier that you can apply to any view. +/// +/// You can apply ``View/modifier(_:)`` directly to a view, but a more common +/// and idiomatic approach uses ``View/modifier(_:)`` to define an extension to +/// ``View`` itself that incorporates the view modifier: +/// +/// ```swift +/// extension View { +/// func indented() -> some View { +/// modifier(Indented()) +/// } +/// } +/// ``` +/// +/// You can then apply indented to any view, similar to this: +/// +/// ```swift +/// Text("Downtown Bus") +/// .indented() +/// ``` +@available(iOS 17.0, macOS 14.0, *) +public protocol ViewModifier { + /// The type of view representing the body. + associatedtype Body: View + + /// The content view type passed to `body()`. + associatedtype Content: View + + /// Gets the current body of the caller. + /// + /// `content` is a proxy for the view that will have the modifier + /// represented by `Self` applied to it. You will typically include a reference + /// to `content` somewhere in the implementation of this method. + @ViewBuilder func body(content: Self.Content) -> Self.Body +} diff --git a/Sources/Slipstream/Views/TextInputAndOutput/Text.swift b/Sources/Slipstream/Views/TextInputAndOutput/Text.swift index a5b06ed..60fcfd7 100644 --- a/Sources/Slipstream/Views/TextInputAndOutput/Text.swift +++ b/Sources/Slipstream/Views/TextInputAndOutput/Text.swift @@ -3,6 +3,7 @@ import SwiftSoup /// A view that displays one or more lines of read-only text. /// /// A text view adds a string to your HTML document. +@available(iOS 17.0, macOS 14.0, *) public struct Text: View { private let content: any StringProtocol @@ -26,7 +27,7 @@ public struct Text: View { } @_documentation(visibility: private) - public func render(_ container: Element) throws { + public func render(_ container: Element, environment: EnvironmentValues) throws { try container.appendText(String(content)) } } diff --git a/Sources/TypeIntrospection/Properties.swift b/Sources/TypeIntrospection/Properties.swift new file mode 100644 index 0000000..b3a128c --- /dev/null +++ b/Sources/TypeIntrospection/Properties.swift @@ -0,0 +1,39 @@ +extension TypeIntrospection { + package struct Properties { + init(parent: TypeIntrospection) { + self.parent = parent + } + + let parent: TypeIntrospection + } +} + +extension TypeIntrospection.Properties: Collection { + package typealias Element = PropertyIntrospection + package typealias Index = Int + + package var count: Int { + Self._getRecursiveChildCount(_in: parent.type) + } + + package var startIndex: Index { + return .zero + } + + package var endIndex: Index { + return count + } + + package func index(after anotherIndex: Index) -> Index { + return anotherIndex + 1 + } + + package subscript(_ index: Index) -> Element { + return PropertyIntrospection(parentType: parent.type, index: index) + } +} + +extension TypeIntrospection.Properties { + @_silgen_name("swift_reflectionMirror_recursiveCount") + private static func _getRecursiveChildCount(_in type: Any.Type) -> Int +} diff --git a/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection-Access.swift b/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection-Access.swift new file mode 100644 index 0000000..2338a54 --- /dev/null +++ b/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection-Access.swift @@ -0,0 +1,59 @@ +extension PropertyIntrospection { + package enum AccessError: Error { + case wrongInstanceType + case wrongValueType + case notMutable + } + + package func getValue(in instance: Instance) throws -> Any { + guard type(of: instance) == parentType else { + throw AccessError.wrongInstanceType + } + return try Self.withRawPointer(_to: instance) { instanceInteriorPointer in + func withProperValueType(_: ProperValue.Type) throws -> ProperValue { + let valuePointer = (instanceInteriorPointer + offset) + .bindMemory(to: ProperValue.self, capacity: 1) + return valuePointer.pointee + } + return try _openExistential(introspection.type, do: withProperValueType(_:)) + } + } + + package func setValue(to value: Value, in instance: inout Instance) throws { + guard isVar else { + throw AccessError.notMutable + } + return try Self.withRawMutablePointer(_to: &instance) { instanceInteriorPointer in + func withProperValueType(_: ProperValue.Type) throws { + guard let value = value as? ProperValue else { + throw AccessError.wrongValueType + } + let valuePointer = (instanceInteriorPointer + offset).bindMemory(to: ProperValue.self, capacity: 1) + valuePointer.pointee = value + } + return try _openExistential(introspection.type, do: withProperValueType(_:)) + } + } +} + +extension PropertyIntrospection { + private static func withRawPointer( + _to instance: Instance, + _execute routine: (UnsafeRawPointer) throws -> RoutineSuccess + ) rethrows -> RoutineSuccess { + return try withUnsafePointer(to: instance) { instancePointer in + let instanceInteriorPointer = UnsafeRawPointer(instancePointer) + return try routine(instanceInteriorPointer) + } + } + + private static func withRawMutablePointer( + _to instance: inout Instance, + _execute routine: (UnsafeMutableRawPointer) throws -> RoutineSuccess + ) rethrows -> RoutineSuccess { + return try withUnsafeMutablePointer(to: &instance) { instancePointer in + let instanceInteriorPointer = UnsafeMutableRawPointer(instancePointer) + return try routine(instanceInteriorPointer) + } + } +} diff --git a/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection.swift b/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection.swift new file mode 100644 index 0000000..a529577 --- /dev/null +++ b/Sources/TypeIntrospection/PropertyIntrospection/PropertyIntrospection.swift @@ -0,0 +1,47 @@ +package struct PropertyIntrospection { + init(parentType: Any.Type, index: Int) { + self.parentType = parentType + var rawConfiguration: _RawConfiguration = ( + _name: nil, + _freeNameFunc: nil, + _isStrong: false, + _isVar: false + ) + let type = Self._getChildMetadata( + _in: parentType, + _at: index, + _configuration: &rawConfiguration + ) + defer { + rawConfiguration._freeNameFunc?(rawConfiguration._name) + } + guard let type else { + preconditionFailure("Type unexpectedly not returned by _getChildMetadata") + } + self.introspection = TypeIntrospection(type: type) + self.offset = Self._getChildOffset(_in: parentType, _at: index) + self.isVar = rawConfiguration._isVar + } + + package let introspection: TypeIntrospection + package let isVar: Bool + + let offset: Int + let parentType: Any.Type +} + +extension PropertyIntrospection { + private typealias _RawName = UnsafePointer + private typealias _RawNameRelease = @convention(c) (_RawName?) -> Void + + /// Source: https://github.com/swiftlang/swift/blob/ca0afe2aed9b56714e4237b840c9d3b89b918a94/stdlib/public/SwiftShims/swift/shims/Reflection.h#L25-L30 + private typealias _RawConfiguration = (_name: _RawName?, _freeNameFunc: _RawNameRelease?, _isStrong: Bool, _isVar: Bool) + + /// Source: https://github.com/swiftlang/swift/blob/ca0afe2aed9b56714e4237b840c9d3b89b918a94/stdlib/public/core/ReflectionMirror.swift#L35-L40 + @_silgen_name("swift_reflectionMirror_recursiveChildMetadata") + private static func _getChildMetadata(_in enclosingType: Any.Type, _at index: Int, _configuration: UnsafeMutablePointer<_RawConfiguration>) -> Any.Type? + + /// Source: https://github.com/swiftlang/swift/blob/ca0afe2aed9b56714e4237b840c9d3b89b918a94/stdlib/public/core/ReflectionMirror.swift#L42-L46 + @_silgen_name("swift_reflectionMirror_recursiveChildOffset") + private static func _getChildOffset(_in enclosingType: Any.Type, _at index: Int) -> Int +} diff --git a/Sources/TypeIntrospection/TypeIntrospection.swift b/Sources/TypeIntrospection/TypeIntrospection.swift new file mode 100644 index 0000000..5abfae6 --- /dev/null +++ b/Sources/TypeIntrospection/TypeIntrospection.swift @@ -0,0 +1,11 @@ +/// Source: https://github.com/gor-gyolchanyan-swift/introspection-kit +package struct TypeIntrospection { + package let type: Any.Type + package init(type: Any.Type) { + self.type = type + } + + package var properties: Properties { + Properties(parent: self) + } +} diff --git a/Tests/SlipstreamTests/EnvironmentTests.swift b/Tests/SlipstreamTests/EnvironmentTests.swift new file mode 100644 index 0000000..fc500de --- /dev/null +++ b/Tests/SlipstreamTests/EnvironmentTests.swift @@ -0,0 +1,44 @@ +import Testing + +import Slipstream + +private struct PathEnvironmentKey: EnvironmentKey { + static let defaultValue: String = "/" +} + +extension EnvironmentValues { + var path: String { + get { self[PathEnvironmentKey.self] } + set { self[PathEnvironmentKey.self] = newValue } + } +} + +private struct InjectorView: View { + let path: String + var body: some View { + ConsumerView() + .environment(\.path, path) + } +} + +private struct DefaultsView: View { + var body: some View { + ConsumerView() + } +} + +private struct ConsumerView: View { + @Environment(\.path) var path + + var body: some View { + Text(path) + } +} + +struct EnvironmentTests { + @Test func rendersProvidedString() throws { + try #expect(renderHTML(InjectorView(path: "Hello, world!")) == "Hello, world!") + try #expect(renderHTML(InjectorView(path: "Slipstream")) == "Slipstream") + try #expect(renderHTML(DefaultsView()) == "/") + } +} diff --git a/Tests/TypeIntrospectionTests/TypeIntrospectionTests.swift b/Tests/TypeIntrospectionTests/TypeIntrospectionTests.swift new file mode 100644 index 0000000..ecd9244 --- /dev/null +++ b/Tests/TypeIntrospectionTests/TypeIntrospectionTests.swift @@ -0,0 +1,86 @@ +import Testing + +import TypeIntrospection + +final class SomeClass { + var string: String + + init(string: String) { + self.string = string + } +} + +private struct SomeType { + let bool: Bool + var mutableString: String + let someClass: SomeClass +} + +struct TypeIntrospectionTests { + + @Test func propertyIntrospection() async throws { + let introspection = TypeIntrospection(type: SomeType.self) + #expect(introspection.type == SomeType.self) + #expect(introspection.properties.count == 3) + + var instance = SomeType(bool: true, mutableString: "hello", someClass: SomeClass(string: "world")) + let boolProperty = introspection.properties[0] + #expect(!boolProperty.isVar) + #expect(try boolProperty.getValue(in: instance) as? Bool == true) + #expect(performing: { + try boolProperty.setValue(to: false, in: &instance) + }, throws: { error in + guard let accessError = error as? PropertyIntrospection.AccessError else { + return false + } + if case .notMutable = accessError { + return true + } + return false + }) + + let stringProperty = introspection.properties[1] + #expect(stringProperty.isVar) + #expect(try stringProperty.getValue(in: instance) as? String == "hello") + try stringProperty.setValue(to: "world", in: &instance) + #expect(try stringProperty.getValue(in: instance) as? String == "world") + + let classProperty = introspection.properties[2] + #expect(!classProperty.isVar) + #expect((try classProperty.getValue(in: instance) as? SomeClass)?.string == "world") + } + + @Test func instanceTypeSafety() async throws { + let introspection = TypeIntrospection(type: SomeType.self) + let instance = Set("some other type") + let boolProperty = introspection.properties[0] + #expect(performing: { + try boolProperty.getValue(in: instance) + }, throws: { error in + guard let accessError = error as? PropertyIntrospection.AccessError else { + return false + } + if case .wrongInstanceType = accessError { + return true + } + return false + }) + } + + @Test func valueTypeSafety() async throws { + let introspection = TypeIntrospection(type: SomeType.self) + var instance = SomeType(bool: true, mutableString: "hello", someClass: SomeClass(string: "world")) + let stringProperty = introspection.properties[1] + #expect(performing: { + try stringProperty.setValue(to: false, in: &instance) + }, throws: { error in + guard let accessError = error as? PropertyIntrospection.AccessError else { + return false + } + if case .wrongValueType = accessError { + return true + } + return false + }) + } +}