diff --git a/Package.swift b/Package.swift index e11b7c9..ea8d804 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.4 /** * Plot diff --git a/README.md b/README.md index 3e3e218..7286785 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- + Swift Package Manager @@ -125,85 +125,202 @@ let html = HTML(.body( This high degree of type safety both results in a really pleasant development experience, and that the HTML and XML documents created using Plot will have a much higher chance of being semantically correct — especially when compared to writing documents and markup using raw strings. -## Defining custom components +## Components -The same context-bound `Node` architecture that gives Plot its high degree of type safety also enables more higher-level components to be defined, which can then be mixed and composed the exact same way as elements defined within Plot itself. +Plot’s `Component` protocol enables you to define and render higher-level components using a very SwiftUI-like API. `Node` and `Component`-based elements can be mixed when creating an HTML document, giving you the flexibility to freely choose which way to implement which part of a website or document. -For example, let’s say that we’re building a news website using Plot, and that we’re rendering `NewsArticle` models in multiple places. Here’s how we could define a reusable `newsArticle` component that’s bound to the context of an HTML document’s ``: +For example, let’s say that we’re building a news website using Plot, and that we’d like to render news articles in several different places. Here’s how we could define a reusable `NewsArticle` component that in turn uses a series of built-in HTML components to render its UI: ```swift -extension Node where Context: HTML.BodyContext { - static func newsArticle(_ article: NewsArticle) -> Self { - return .article( - .class("news"), - .img(.src(article.imagePath)), - .h1(.text(article.title)), - .span( - .class("description"), - .text(article.description) - ) - ) +struct NewsArticle: Component { + var imagePath: String + var title: String + var description: String + + var body: Component { + Article { + Image(url: imagePath, description: "Header image") + H1(title) + Span(description).class("description") + } + .class("news") } } ``` -With the above in place, we can now render any of our `NewsArticle` models using the exact same syntax as we use for built-in elements: +As the above example shows, modifiers can also be applied to components to set the value of attributes, such as `class` or `id`. + +To then integrate the above component into a `Node`-based hierarchy, we can simply wrap it within a `Node` using the `.component` API, like this: ```swift func newsArticlePage(for article: NewsArticle) -> HTML { return HTML(.body( .div( .class("wrapper"), - .newsArticle(article) + .component(article) ) )) } ``` +You can also directly inline `Node`-based elements within a component’s `body`, which gives you complete freedom to mix and match between the two APIs: + +```swift +struct Banner: Component { + var title: String + var imageURL: URLRepresentable + + var body: Component { + Div { + Node.h2(.text(title)) + Image(imageURL) + } + .class("banner") + } +} +``` + It’s highly recommended that you use the above component-based approach as much as possible when building websites and documents with Plot — as doing so will let you build up a growing library of reusable components, which will most likely accelerate your overall workflow over time. +However, note that the `Component` API can currently only be used to define elements that appear within the `` of an HTML page. For `` elements, or non-HTML elements, the `Node`-based API always has to be used. + +Another important note is that, although Plot has been heavily optimized across the board, `Component`-based elements do require a bit of extra processing compared to `Node`-based ones — so in situations where maximum performance is required, you might want to stick to the `Node`-based API. + +## Using the component environment + +Just like SwiftUI views, Plot components can pass values downwards through a hierarchy using an *environment API*. Once a value has been entered into the environment using an `EnvironmentKey` and the `environmentValue` modifier, it can then be retrieved by defining a property marked with the `@EnvironmentValue` attribute within a `Component` implementation. + +In the following example, the environment API is used to enable a `Page` component to assign a given `class` to all `ActionButton` components that appear within its hierarchy: + +```swift +// We start by defining a custom environment key that can be +// used to enter String values into the environment: +extension EnvironmentKey where Value == String { + static var actionButtonClass: Self { + Self(defaultValue: "action-button") + } +} + +struct Page: Component { + var body: Component { + Div { + InfoView(title: "...", text: "...") + } + // Here we enter a custom action button class + // into the environment, which will apply to + // all child components within our above Div: + .environmentValue("action-button-large", + key: .actionButtonClass + ) + } +} + +// Our info view doesn't have to have any awareness of +// our environment value. Plot will automatically pass +// it down to the action buttons defined below: +struct InfoView: Component { + var title: String + var text: String + + var body: Component { + Div { + H2(title) + Paragraph(text) + ActionButton(title: "OK") + ActionButton(title: "Cancel") + } + .class("info-view") + } +} + +struct ActionButton: Component { + var title: String + + // Here we pick up the current environment value for + // our custom "actionButtonClass" key, which in this + // example will be the value that our "Page" component + // entered into the environment: + @EnvironmentValue(.actionButtonClass) var className + + var body: Component { + Button(title).class(className) + } +} +``` + +Plot also ships with several components that utilize the environment API for customization. For example, you can change the style of all `List` components within a hierarchy using the `listStyle` key/modifier, and the `linkRelationship` key/modifier lets you tweak the `rel` attribute of all `Link` components within a hierarchy. + ## Inline control flow -Since Plot is focused on static site generation, it also ships with several control flow mechanisms that let you inline logic when using its DSL. For example, using the `.if()` command, you can optionally add a node only when a given condition is `true`: +Since Plot is focused on static site generation, it also ships with several control flow mechanisms that let you inline logic when using either its `Node`-based or `Component`-based APIs. For example, using the `.if()` command, you can optionally add a node only when a given condition is `true`, and within a component’s `body`, you can simply inline a regular `if` statement to do the same thing: ```swift let rating: Rating = ... +// When using the Node-based API: let html = HTML(.body( .if(rating.hasEnoughVotes, .span("Average score: \(rating.averageScore)") ) )) + +// When using the Component API: +let html = HTML { + if rating.hasEnoughVotes { + Span("Average score: \(rating.averageScore)") + } +} ``` -You can also attach an `else` clause to the `.if()` command as well, which will act as a fallback node to be displayed when the condition is `false`: +You can also attach an `else` clause to the node-based `.if()` command as well, which will act as a fallback node to be displayed when the command’s condition is `false`. You can also use a standard `else` clause when using the component API: ```swift +// When using the Node-based API: let html = HTML(.body( .if(rating.hasEnoughVotes, .span("Average score: \(rating.averageScore)"), else: .span("Not enough votes yet.") ) )) + +// When using the Component API: +let html = HTML { + if rating.hasEnoughVotes { + Span("Average score: \(rating.averageScore)") + } else { + Span("Not enough votes yet.") + } +} ``` -Optional values can also be unwrapped inline using the `.unwrap()` command, which takes an optional to unwrap, and a closure used to transform its value into a node — for example to conditionally display a part of an HTML page only if a user is logged in: +Optional values can also be unwrapped inline using the `Node`-based `.unwrap()` command, which takes an optional to unwrap, and a closure used to transform its value into a node. When using the `Component`-based API, you can simply use a standard `if let` expression to do the same thing. + +Here’s how those capabilities could be used to conditionally display a part of an HTML page only if a user is logged in. ```swift let user: User? = loadUser() +// When using the Node-based API: let html = HTML(.body( .unwrap(user) { .p("Hello, \($0.name)") } )) + +// When using the Component-based API: +let html = HTML { + if let user = user { + Paragraph("Hello, \(user.name)") + } +} ``` -Just like `.if()`, the `.unwrap()` command can also be passed an `else` clause that will be used if the optional being unwrapped turned out to be `nil`: +Just like `.if()`, the `.unwrap()` command can also be passed an `else` clause that will be used if the optional being unwrapped turned out to be `nil` (and the equivalent logic can once again be implemented using a standard `else` clause when using the `Component`-based API): ```swift let user: User? = loadUser() +// When using the Node-based API: let html = HTML(.body( .unwrap(user, { .p("Hello, \($0.name)") @@ -211,19 +328,46 @@ let html = HTML(.body( else: .text("Please log in") ) )) + +// When using the Component-based API: +let html = HTML { + if let user = user { + Paragraph("Hello, \(user.name)") + } else { + Text("Please log in") + } +} ``` -Finally, the `.forEach()` command can be used to transform any Swift `Sequence` into a group of nodes, which is incredibly useful when constructing lists: +Finally, the `.forEach()` command can be used to transform any Swift `Sequence` into a group of nodes, which is incredibly useful when constructing `Node`-based lists. When building `Component`-based lists, you could either directly pass your sequence to the built-in `List` component, or use a `for` loop: ```swift let names: [String] = ... +// When using the Node-based API: let html = HTML(.body( .h2("People"), .ul(.forEach(names) { .li(.class("name"), .text($0)) }) )) + +// When using the Component-based API: +let html = HTML { + H2("People") + + // Passing our array directly to List: + List(names) { name in + ListItem(name).class("name") + } + + // Using a manual for loop within a List closure: + List { + for name in names { + ListItem(name).class("name") + } + } +} ``` Using the above control flow mechanisms, especially when combined with the approach of defining custom components, lets you build really flexible templates, documents and HTML pages — all in a completely type-safe way. @@ -235,12 +379,25 @@ While Plot aims to cover as much of the standards associated with the document f Thankfully, Plot also makes it trivial to define custom elements and attributes — which is both useful when building more free-form XML documents, and as an *“escape hatch”* when Plot does not yet support a given part of a standard: ```swift +// When using the Node-based API: let html = HTML(.body( .element(named: "custom", text: "Hello..."), .p( .attribute(named: "custom", value: "...world!") ) )) + +// When using the Component-based API: +let html = HTML { + Element(name: "custom") { + Text("Hello...") + } + + Paragraph().attribute( + named: "custom", + value: "...world!" + ) +} ``` While the above APIs are great for constructing one-off custom elements, or for temporary working around a limitation in Plot’s built-in functionality, it’s (in most cases) recommended to instead either: @@ -295,6 +452,17 @@ let header = Node.header( let string = header.render() ``` +Just like nodes, components can also be rendered on their own: + +```swift +let header = Header { + H1("Title") + Span("Description") +} + +let string = header.render() +``` + Plot was built with performance in mind, so regardless of how you render a document, the goal is for that rendering process to be as fast as possible — with very limited node tree traversal and as little string copying and interpolation as possible. ## RSS feeds, podcasting, and site maps @@ -342,7 +510,7 @@ For more information about what data is required to build a podcast feed, see [A ## System requirements -To be able to successfully use Plot, make sure that your system has Swift version 5.2 (or later) installed. If you’re using a Mac, also make sure that `xcode-select` is pointed at an Xcode installation that includes the required version of Swift, and that you’re running macOS Catalina (10.15) or later. +To be able to successfully use Plot, make sure that your system has Swift version 5.4 (or later) installed. If you’re using a Mac, also make sure that `xcode-select` is pointed at an Xcode installation that includes the required version of Swift, and that you’re running macOS Big Sur (11.0) or later. Please note that Plot **does not** officially support any form of beta software, including beta versions of Xcode and macOS, or unreleased versions of Swift. @@ -354,7 +522,7 @@ Plot is distributed using the [Swift Package Manager](https://swift.org/package- let package = Package( ... dependencies: [ - .package(url: "https://github.com/johnsundell/plot.git", from: "0.1.0") + .package(url: "https://github.com/johnsundell/plot.git", from: "0.9.0") ], ... ) @@ -375,12 +543,15 @@ Plot consists of four core parts, that together make up both its DSL and its ove - [`Node`](Sources/Plot/API/Node.swift) is the core building block for all elements and attributes within any Plot document. It can represent elements and attributes, as well as text content and groups of nodes. Each node is bound to a `Context` type, which determines which kind of DSL APIs that it gets access to (for example `HTML.BodyContext` for nodes placed within the `` of an HTML page). - [`Element`](Sources/Plot/API/Element.swift) represents an element, and can either be opened and closed using two separate tags (like ``) or self-closed (like ``). You normally don’t have to interact with this type when using Plot, since you can create instances of it through its DSL. - [`Attribute`](Sources/Plot/API/Attribute.swift) represents an attribute attached to an element, such as the `href` of an `` element, or the `src` of an `` element. You can either construct `Attribute` values through its initializer, or through the DSL, using the `.attribute()` command. +- The [`Component`](Sources/Plot/API/Component.swift) protocol is used to define components in a very SwiftUI-like way. Every component needs to implement a `body` property, in which its rendered output can be constructed using either other components, or `Node`-based elements. - [`Document` and `DocumentFormat`](Sources/Plot/API/Document.swift) represent documents of a given format, such as `HTML`, `RSS` and `PodcastFeed`. These are the top level types that you use in order to start a document building session using Plot’s DSL. Plot makes heavy use of a technique known as *[Phantom Types](https://www.swiftbysundell.com/articles/phantom-types-in-swift)*, which is when types are used as “markers” for the compiler, to be able to enforce type safety through [generic constraints](https://www.swiftbysundell.com/articles/using-generic-type-constraints-in-swift-4). Both `DocumentFormat`, and the `Context` of a node, element or attribute, are used this way — as these types are never instantiated, but rather just there to associate their values with a given context or format. Plot also uses a very [lightweight API design](https://www.swiftbysundell.com/articles/lightweight-api-design-in-swift), minimizing external argument labels in favor of reducing the amount of syntax needed to render a document — giving its API a very “DSL-like” design. +The `Component` API uses the [Result Builders](https://swiftbysundell.com/articles/deep-dive-into-swift-function-builders) and [Property Wrappers](https://swiftbysundell.com/articles/property-wrappers-in-swift) language features to bring its very SwiftUI-like API to life. + ## Compatibility with standards Plot’s ultimate goal to be fully compatible with all standards that back the document formats that it supports. However, being a very young project, it will most likely need the community’s help to move it closer to that goal. @@ -393,6 +564,8 @@ The following standards are intended to be covered by Plot’s DSL: - [Apple’s RSS extensions for podcasts](https://help.apple.com/itc/podcasts_connect/#/itcbaf351599) - [The Sitemaps XML format](https://www.sitemaps.org/protocol.html) +Note that the `Component` API currently only covers a subset of the HTML 5.0 spec, and can currently only be used to define elements within the `` of an HTML page. + If you discover an element or attribute that’s missing, please [add it](CONTRIBUTING.md#adding-a-new-node-type) and open a Pull Request with that addition. ## Credits, alternatives and focus @@ -409,10 +582,10 @@ Plot is developed completely in the open, and your contributions are more than w Before you start using Plot in any of your projects, it’s highly recommended that you spend a few minutes familiarizing yourself with its documentation and internal implementation, so that you’ll be ready to tackle any issues or edge cases that you might encounter. -Since this is a very young project, it’s likely to have many limitations and missing features, which is something that can really only be discovered and addressed as more people start using it. While Plot is used in production to build and render all of [Swift by Sundell](https://swiftbysundell.com), it’s recommended that you first try it out for your specific use case, to make sure it supports the features that you need. +Since this is still a young project, it’s likely to have many limitations and missing features, which is something that can really only be discovered and addressed as more people start using it. While Plot is used in production to build and render all of [Swift by Sundell](https://swiftbysundell.com), it’s recommended that you first try it out for your specific use case, to make sure it supports the features that you need. -This project does [not come with GitHub Issues-based support](CONTRIBUTING.md#bugs-feature-requests-and-support), and users are instead encouraged to become active participants in its continued development — by fixing any bugs that they encounter, or by improving the documentation wherever it’s found to be lacking. +This project does [not come with GitHub Issues-based support](CONTRIBUTING.md#bugs-feature-requests-and-support), or any other kind of direct support channels, and users are instead encouraged to become active participants in its continued development — by fixing any bugs that they encounter, or by improving the documentation wherever it’s found to be lacking. If you wish to make a change, [open a Pull Request](https://github.com/JohnSundell/Plot/pull/new) — even if it just contains a draft of the changes you’re planning, or a test that reproduces an issue — and we can discuss it further from there. See [Plot’s contribution guide](CONTRIBUTING.md) for more information about how to contribute to this project. -Hope you’ll enjoy using Plot! +Hope you’ll enjoy using Plot! \ No newline at end of file diff --git a/Sources/Plot/API/Attribute.swift b/Sources/Plot/API/Attribute.swift index 3a3f8a5..932b24e 100644 --- a/Sources/Plot/API/Attribute.swift +++ b/Sources/Plot/API/Attribute.swift @@ -15,16 +15,23 @@ public struct Attribute { public var name: String /// The attribute's value public var value: String? - /// Whether the attribute should be completely ignored if it has no value + /// Whether the attribute's value should replace any existing one that has + /// already been added to a given element for the same attribute name. + public var replaceExisting: Bool + /// Whether the attribute should be completely ignored if it has no value. public var ignoreIfValueIsEmpty: Bool /// Create a new `Attribute` instance with a name and a value, and optionally - /// opt out of ignoring the attribute if its value is empty. + /// opt out of ignoring the attribute if its value is empty, and decide whether the + /// attribute should replace any existing one that's already been added to an element + /// for the same name. public init(name: String, value: String?, + replaceExisting: Bool = true, ignoreIfValueIsEmpty: Bool = true) { self.name = name self.value = value + self.replaceExisting = replaceExisting self.ignoreIfValueIsEmpty = ignoreIfValueIsEmpty } } @@ -51,23 +58,16 @@ internal extension Attribute where Context == Any { } } -internal protocol AnyAttribute { - var name: String { get } - func render() -> String +extension Attribute: NodeConvertible { + public var node: Node { .attribute(self) } } extension Attribute: AnyAttribute { func render() -> String { - guard let value = value, !value.isEmpty else { + guard let value = nonEmptyValue else { return ignoreIfValueIsEmpty ? "" : name } return "\(name)=\"\(value)\"" } } - -extension Attribute: NodeConvertible { - func asNode() -> AnyNode { - Node.attribute(self) - } -} diff --git a/Sources/Plot/API/Component.swift b/Sources/Plot/API/Component.swift new file mode 100644 index 0000000..b3c9292 --- /dev/null +++ b/Sources/Plot/API/Component.swift @@ -0,0 +1,144 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Protocol used to define components that can be rendered into HTML. +/// +/// Implement custom types conforming to this protocol to create your own +/// HTML components that can then be rendered using either the built-in +/// component types that Plot ships with, or using the `Node`-based API. +/// +/// You can freely mix and match components and nodes when implementing +/// a component, and any component can be converted into a `Node`, either +/// by creating a `.component` node, or by calling `convertToNode()` +/// on a component. +/// +/// Modifiers can be applied to components to change attributes like `class` +/// and `id`, and using the `EnvironmentValue` property wrapper and the +/// `EnvironmentKey` type, you can propagate environmental values through +/// a hierarchy of nodes and components. +public protocol Component: Renderable { + /// The underlying component that should be used to render this component. + /// Can either be a `Node`, another `Component`, or a group of components + /// created using the `ComponentGroup` type. + var body: Component { get } +} + +public extension Component { + /// A convenience type alias for a closure that creates the contents of a + /// given component. Closures of this type are typically marked with the + /// `@ComponentBuilder` attribute to enable Plot's DSL to be used when + /// implementing them. + typealias ContentProvider = () -> ComponentGroup + + /// Add an attribute to the HTML element used to render this component. + /// - parameter name: The name of the attribute to add. + /// - parameter value: The value that the attribute should have. + /// - parameter replaceExisting: Whether any existing attribute with the + /// same name should be replaced by the new attribute. Defaults to `true`, + /// and if set to `false`, this attribute's value will instead be appended + /// to any existing one, separated by a space. + /// - parameter ignoreValueIfEmpty: Whether the attribute should be ignored if + /// its value is `nil` or empty. Defaults to `true`, and if set to `false`, + /// only the attribute's name will be rendered if its value is empty. + func attribute(named name: String, + value: String?, + replaceExisting: Bool = true, + ignoreValueIfEmpty: Bool = true) -> Component { + attribute(Attribute( + name: name, + value: value, + replaceExisting: replaceExisting, + ignoreIfValueIsEmpty: ignoreValueIfEmpty + )) + } + + /// Add an attribute to the HTML element used to render this component. + /// - parameter attribute: The attribute to add. See the documentation for + /// the `Attribute` type for more information. + func attribute(_ attribute: Attribute) -> Component { + if let group = self as? ComponentGroup { + return ComponentGroup(members: group.members.map { + $0.attribute(attribute) + }) + } + + if var modified = self as? ModifiedComponent { + modified.deferredAttributes.append(attribute) + return modified + } + + return ModifiedComponent( + base: self, + deferredAttributes: [attribute] + ) + } + + /// Place a value into the environment used to render this component and any + /// of its child components. An environment value will be passed downwards + /// through a component/node hierarchy until its overridden by another value + /// for the same key. + /// - parameter value: The value to add. Must match the type of the key that + /// it's being added for. This value will override any value that was assigned + /// by a parent component for the same key, or the key's default value. + /// - parameter key: The key to associate the value with. You can either use any + /// of the built-in key definitions that Plot ships with, or define your own. + /// See `EnvironmentKey` for more information. + func environmentValue(_ value: T, key: EnvironmentKey) -> Component { + let override = Environment.Override(key: key, value: value) + + if var modified = self as? ModifiedComponent { + modified.environmentOverrides.append(override) + return modified + } + + return ModifiedComponent( + base: self, + environmentOverrides: [override] + ) + } + + /// Convert this component into a `Node`, with either an inferred or explicit + /// context. Use this API when you want to embed a component into a `Node`-based + /// hierarchy. Calling this method is equivalent to creating a `.component` node + /// using this component. + /// - parameter context: The context of the returned node (can typically be + /// inferred by the compiler based on the call site). + func convertToNode(withContext context: T.Type = T.self) -> Node { + .component(self) + } + + func render(indentedBy indentationKind: Indentation.Kind?) -> String { + var renderer = Renderer(indentationKind: indentationKind) + renderer.renderComponent(self) + return renderer.result + } +} + +internal extension Component { + func wrappedInElement(named wrappingElementName: String) -> Component { + wrapped(using: ElementWrapper( + wrappingElementName: wrappingElementName + )) + } + + func wrapped(using wrapper: ElementWrapper) -> Component { + guard !(self is EmptyComponent) else { + return self + } + + if let group = self as? ComponentGroup { + return ComponentGroup( + members: group.members.map { + $0.wrapped(using: wrapper) + } + ) + } + + return Node.wrappingComponent(self, using: wrapper) + } +} diff --git a/Sources/Plot/API/ComponentAttributes.swift b/Sources/Plot/API/ComponentAttributes.swift new file mode 100644 index 0000000..ca3b37c --- /dev/null +++ b/Sources/Plot/API/ComponentAttributes.swift @@ -0,0 +1,99 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +public extension Component { + /// Assign an accessibility label to this component's element, which + /// is used by assistive technologies to get a text representation of it. + /// - parameter label: The label to assign. + func accessibilityLabel(_ label: String) -> Component { + attribute(named: "aria-label", value: label) + } + + /// Assign a class name to this component's element. May also be a list + /// of space-separated class names. + /// - parameter className: The class or list of classes to assign. + /// - parameter replaceExisting: Whether the new class name should replace + /// any existing one. Defaults to `false`, which will instead cause the + /// new class name to be appended to any existing one, separated by a space. + func `class`(_ className: String, replaceExisting: Bool = false) -> Component { + attribute(named: "class", + value: className, + replaceExisting: replaceExisting) + } + + /// Add a `data-` attribute to this component's element. + /// - parameter name: The name of the attribute to add. The name will be + /// prefixed with `data-`. + /// - parameter value: The attribute's string value. + func data(named name: String, value: String) -> Component { + attribute(named: "data-" + name, value: value) + } + + /// Assign an ID attribute to this component's element. + /// - parameter id: The ID to assign. + func id(_ id: String) -> Component { + attribute(named: "id", value: id) + } + + /// Assign a directionality to this component's element. + /// - parameter directionality: The directionality to assign. + func directionality(_ directionality: Directionality) -> Component { + attribute(named: "dir", value: directionality.rawValue) + } + + /// Assign whether this component hierarchy's `Input` components should have + /// autocomplete turned on or off. This value is placed in the environment, and + /// is thus inherited by all child components. Note that this modifier only + /// affects components, not elements created using the `Node.input` API, or + /// manually created input elements. + /// - parameter isEnabled: Whether autocomplete should be enabled. + func autoComplete(_ isEnabled: Bool) -> Component { + environmentValue(isEnabled, key: .isAutoCompleteEnabled) + } + + /// Assign a given `HTMLAnchorRelationship` to all `Link` components within + /// this component hierarchy. Affects the `rel` attribute on the generated + /// `` elements. This value is placed in the environment, and is thus + /// inherited by all child components. Note that this modifier only affects + /// components, not elements created using the `Node.a` API, or manually + /// created anchor elements. + /// - parameter relationship: The relationship to assign. + func linkRelationship(_ relationship: HTMLAnchorRelationship?) -> Component { + environmentValue(relationship, key: .linkRelationship) + } + + /// Assign a given `HTMLAnchorTarget` to all `Link` components within this + /// component hierarchy. Affects the `target` attribute on the generated + /// `` elements. This value is placed in the environment, and is thus + /// inherited by all child components. Note that this modifier only affects + /// components, not elements created using the `Node.a` API, or manually + /// created anchor elements. + /// - parameter target: The target to assign. + func linkTarget(_ target: HTMLAnchorTarget?) -> Component { + environmentValue(target, key: .linkTarget) + } + + /// Assign a given `HTMLListStyle` to all `List` components within this + /// component hierarchy. You can use this modifier to decide whether lists + /// should be rendered as ordered or unordered, or even use a completely + /// custom style. This value is placed in the environment, and is thus + /// inherited by all child components. Note that this modifier only affects + /// components, not elements created using the `Node.ul` or `Node.ol` APIs, + /// or manually created list elements. + /// - parameter style: The style to assign. + func listStyle(_ style: HTMLListStyle) -> Component { + environmentValue(style, key: .listStyle) + } + + /// Assign a given set of inline CSS styles to this component's element. + /// - parameter css: A string containing the CSS code that should be assigned + /// to this component's `style` attribute. + func style(_ css: String) -> Component { + attribute(named: "style", value: css) + } +} diff --git a/Sources/Plot/API/ComponentBuilder.swift b/Sources/Plot/API/ComponentBuilder.swift new file mode 100644 index 0000000..c3e63f4 --- /dev/null +++ b/Sources/Plot/API/ComponentBuilder.swift @@ -0,0 +1,46 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Result builder used to combine all of the `Component` expressions that appear +/// within a given attributed scope into a single `ComponentGroup`. +/// +/// You can annotate any function or closure with the `@ComponentBuilder` attribute +/// to have its contents be processed by this builder. Note that you never have to +/// call any of the methods defined within this type directly. Instead, the Swift +/// compiler will automatically map your expressions to calls into this builder type. +@resultBuilder public enum ComponentBuilder { + /// Build a `ComponentGroup` from a list of components. + /// - parameter components: The components that should be included in the group. + public static func buildBlock(_ components: Component...) -> ComponentGroup { + ComponentGroup(members: components) + } + + /// Build a flattened `ComponentGroup` from an array of component groups. + /// - parameter groups: The component groups to flatten into a single group. + public static func buildArray(_ groups: [ComponentGroup]) -> ComponentGroup { + ComponentGroup(members: groups.flatMap { $0 }) + } + + /// Pick the first `ComponentGroup` within a conditional statement. + /// - parameter component: The component to pick. + public static func buildEither(first component: ComponentGroup) -> ComponentGroup { + component + } + + /// Pick the second `ComponentGroup` within a conditional statement. + /// - parameter component: The component to pick. + public static func buildEither(second component: ComponentGroup) -> ComponentGroup { + component + } + + /// Build a `ComponentGroup` from an optional group. + /// - parameter component: The optional to transform into a concrete group. + public static func buildOptional(_ component: ComponentGroup?) -> ComponentGroup { + component ?? ComponentGroup(members: []) + } +} diff --git a/Sources/Plot/API/ComponentContainer.swift b/Sources/Plot/API/ComponentContainer.swift new file mode 100644 index 0000000..dda6ec4 --- /dev/null +++ b/Sources/Plot/API/ComponentContainer.swift @@ -0,0 +1,46 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Protocol adopted by components that can act as a container for +/// other components. Plot ships with a number of implementations of +/// this protocol (such as `Div`, `List`, `Article`, and so on), and +/// you can easily create your own as well by implementing the required +/// initializer. +public protocol ComponentContainer: Component { + /// Initialize this component with a closure that defines its content. + /// - parameter content: The component content that should be contained + /// within this component. + init(@ComponentBuilder content: @escaping ContentProvider) +} + +public extension ComponentContainer { + /// Initialize this component without any content. + init() { + self.init {} + } + + /// Initialize this container with a single content component. + /// - parameter component: The component to include as content. + init(_ component: Component) { + self.init { component } + } + + /// Initialize this container with a string as its content. + /// - parameter string: The text that this component should contain. + /// Any special characters that can't be rendered as-is will be escaped. + init(_ string: String) { + self.init { Node.text(string) } + } + + /// Initialize this container with a raw HTML string. + /// - parameter html: The HTML that this component should contain. + /// Won't be processed in any way, and will instead be rendered as-is. + init(html: String) { + self.init { Node.raw(html) } + } +} diff --git a/Sources/Plot/API/ComponentGroup.swift b/Sources/Plot/API/ComponentGroup.swift new file mode 100644 index 0000000..f5226e6 --- /dev/null +++ b/Sources/Plot/API/ComponentGroup.swift @@ -0,0 +1,40 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Type used to define a group of components +/// +/// The `members` contained within a `ComponentGroup` act as one +/// unit when passed around, with the exception that any modifier +/// that is applied to a group will be applied to each member +/// individually. So, for example, applying the `class` modifier +/// to a group results in each element within that group getting +/// that class name assigned to it. +public struct ComponentGroup: Component { + /// The group's members. Will be rendered in order. + public var members: [Component] + public var body: Component { Node.components(members) } + + /// Create a new group with a given set of member components. + /// - parameter members: The components that should be included + /// within the group. Will be rendered in order. + public init(members: [Component]) { + self.members = members + } +} + +extension ComponentGroup: ComponentContainer { + public init(@ComponentBuilder content: () -> Self) { + self = content() + } +} + +extension ComponentGroup: Sequence { + public func makeIterator() -> Array.Iterator { + members.makeIterator() + } +} diff --git a/Sources/Plot/API/Directionality.swift b/Sources/Plot/API/Directionality.swift new file mode 100644 index 0000000..7547e70 --- /dev/null +++ b/Sources/Plot/API/Directionality.swift @@ -0,0 +1,12 @@ +/** + * Plot + * Copyright (c) John Sundell 2021 + * MIT license, see LICENSE file for details + */ + +/// Enum defining an element's text directionality. +public enum Directionality: String { + case leftToRight = "ltr" + case rightToLeft = "rtl" + case auto = "auto" +} diff --git a/Sources/Plot/API/Document.swift b/Sources/Plot/API/Document.swift index 90b02dc..3f54697 100644 --- a/Sources/Plot/API/Document.swift +++ b/Sources/Plot/API/Document.swift @@ -54,10 +54,6 @@ public extension Document { } } -extension Document: Renderable { - public func render(indentedBy indentationKind: Indentation.Kind?) -> String { - let indentation = indentationKind.map(Indentation.init) - let html = elements.map { $0.render(indentedBy: indentation) } - return html.joined(separator: indentationKind == nil ? "" : "\n") - } +extension Document: NodeConvertible { + public var node: Node { .document(self) } } diff --git a/Sources/Plot/API/Element.swift b/Sources/Plot/API/Element.swift index f3b7f4d..0cecffa 100644 --- a/Sources/Plot/API/Element.swift +++ b/Sources/Plot/API/Element.swift @@ -10,7 +10,7 @@ import Foundation /// You normally don't construct `Element` values manually, but rather use Plot's /// various DSL APIs to create them, for example by creating a `` tag using /// `.body()`, or a `

` tag using `.p()`. -public struct Element { +public struct Element: AnyElement { /// The name of the element public var name: String /// How the element is closed, for example if it's self-closing or if it can @@ -22,17 +22,8 @@ public struct Element { } public extension Element { - /// Enum defining how a given element should be closed - enum ClosingMode { - /// The standard (default) closing mode, which creates a pair of opening - /// and closing tags, for example ``. - case standard - /// For elements that are never closed, for example the leading declaration - /// tags found at the top of XML documents. - case neverClosed - /// For elements that close themselves, for example ``. - case selfClosing - } + /// Convenience shorthand for `ElementClosingMode`. + typealias ClosingMode = ElementClosingMode /// Create a custom element with a given name and array of child nodes. /// - parameter name: The name of the element to create. @@ -46,20 +37,21 @@ public extension Element { /// - parameter attributes The attributes to add to the element. static func selfClosed(named name: String, attributes: [Attribute]) -> Element { - Element(name: name, closingMode: .selfClosing, nodes: attributes.asNodes()) + Element(name: name, closingMode: .selfClosing, nodes: attributes.map(\.node)) } } -extension Element: Renderable { - public func render(indentedBy indentation: Indentation?) -> String { - let renderer = ElementRenderer( - elementName: name, - paddingCharacter: paddingCharacter, - indentation: indentation - ) +extension Element: NodeConvertible { + public var node: Node { .element(self) } +} - nodes.forEach { $0.render(into: renderer) } +extension Element: Component where Context == Any { + public var body: Component { node } - return renderer.render(withClosingMode: closingMode) + public init( + name: String, + @ComponentBuilder content: @escaping ContentProvider + ) { + self.init(name: name, nodes: [Node.component(content())]) } } diff --git a/Sources/Plot/API/ElementClosingMode.swift b/Sources/Plot/API/ElementClosingMode.swift new file mode 100644 index 0000000..bf9c70e --- /dev/null +++ b/Sources/Plot/API/ElementClosingMode.swift @@ -0,0 +1,19 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Enum defining how a given element should be closed. +public enum ElementClosingMode { + /// The standard (default) closing mode, which creates a pair of opening + /// and closing tags, for example ``. + case standard + /// For elements that are never closed, for example the leading declaration + /// tags found at the top of XML documents. + case neverClosed + /// For elements that close themselves, for example ``. + case selfClosing +} diff --git a/Sources/Plot/API/ElementComponent.swift b/Sources/Plot/API/ElementComponent.swift new file mode 100644 index 0000000..4ae10f2 --- /dev/null +++ b/Sources/Plot/API/ElementComponent.swift @@ -0,0 +1,24 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Type representing a component that's directly based on an HTML element. +/// You typically don't have to use this type directly. Instead, Plot ships +/// with a number of type aliases that provide easier access to specialized +/// versions of this type, such as `Div`, `Header`, `Article`, and so on. +/// See the `ElementDefinitions` namespace enum for a list of all such aliases. +public struct ElementComponent: ComponentContainer { + @ComponentBuilder public var content: ContentProvider + + public init(@ComponentBuilder content: @escaping ContentProvider) { + self.content = content + } + + public var body: Component { + Definition.wrapper(.component(content())) + } +} diff --git a/Sources/Plot/API/ElementDefinition.swift b/Sources/Plot/API/ElementDefinition.swift new file mode 100644 index 0000000..8694ad7 --- /dev/null +++ b/Sources/Plot/API/ElementDefinition.swift @@ -0,0 +1,20 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Protocol used to make it possible to use a `Node`-based element with +/// the `ElementComponent` type. You typically don't have to conform to +/// this protocol yourself, unless you want to add first-class component +/// support for an HTML `Node` that Plot doesn't yet map to natively. +public protocol ElementDefinition { + /// The context that the element's content nodes should all have. + associatedtype InputContext + /// The context that the element's own node should have. + associatedtype OutputContext + /// A closure that can be used to wrap a list of nodes into an element node. + static var wrapper: (Node...) -> Node { get } +} diff --git a/Sources/Plot/API/EmptyComponent.swift b/Sources/Plot/API/EmptyComponent.swift new file mode 100644 index 0000000..7a9dc9f --- /dev/null +++ b/Sources/Plot/API/EmptyComponent.swift @@ -0,0 +1,17 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// A type that represents an empty, non-rendered component. It's typically +/// used within contexts where some kind of `Component` needs to be returned, +/// but when you don't actually want to render anything. Modifiers have no +/// affect on this component. +public struct EmptyComponent: Component { + /// Initialize an empty component. + public init() {} + public var body: Component { Node.empty } +} diff --git a/Sources/Plot/API/EnvironmentKey.swift b/Sources/Plot/API/EnvironmentKey.swift new file mode 100644 index 0000000..e5eb081 --- /dev/null +++ b/Sources/Plot/API/EnvironmentKey.swift @@ -0,0 +1,85 @@ +/** +* Plot +* Copyright (c) John Sundell 2021 +* MIT license, see LICENSE file for details +*/ + +import Foundation + +/// Type used to define an environment key, which can be used to pass a given +/// value downward through a component/node hierarchy until its overridden by +/// another value for the same key. You can place values into the environment +/// using the `environmentValue` modifier, and you can then retrieve those +/// values within any component using the `EnvironmentValue` property wrapper. +public struct EnvironmentKey { + internal let identifier: StaticString + internal let defaultValue: Value + + /// Initialize a key with an explicit identifier and a default value. + /// - parameter identifier: The identifier that the key should have. Must + /// be a static string that's defined using a compile time literal. + /// - parameter defaultValue: The default value that should be provided + /// to components when no parent component assigned a value for this key. + public init(identifier: StaticString, defaultValue: Value) { + self.identifier = identifier + self.defaultValue = defaultValue + } +} + +public extension EnvironmentKey { + /// Initialize a key with an inferred identifier and a default value. The + /// key's identifier will be computed based on the name of the property or + /// function that created it. + /// - parameter defaultValue: The default value that should be provided + /// to components when no parent component assigned a value for this key. + /// - parameter autoIdentifier: This parameter will be filled in by the + /// compiler based on the name of the call site's enclosing function/property. + init(defaultValue: Value, autoIdentifier: StaticString = #function) { + self.init(identifier: autoIdentifier, defaultValue: defaultValue) + } +} + +public extension EnvironmentKey { + /// Initialize a key with an explicit identifier. + /// - parameter identifier: The identifier that the key should have. Must + /// be a static string that's defined using a compile time literal. + init(identifier: StaticString) where Value == T? { + self.init(identifier: identifier, defaultValue: nil) + } + + /// Initialize a key with an inferred identifier. The key's identifier will + /// be computed based on the name of the property or function that created it. + /// - parameter autoIdentifier: This parameter will be filled in by the + /// compiler based on the name of the call site's enclosing function/property. + init(autoIdentifier: StaticString = #function) where Value == T? { + self.init(identifier: autoIdentifier, defaultValue: nil) + } +} + +public extension EnvironmentKey where Value == HTMLAnchorRelationship? { + /// Key used to define a relationship for `Link` components. The default is `nil` + /// (that is, no explicitly defined relationship). See the `linkRelationship` + /// modifier for more information. + static var linkRelationship: Self { .init() } +} + +public extension EnvironmentKey where Value == HTMLAnchorTarget? { + /// Key used to define a target for `Link` components. The default is `nil` + /// (that is, no explicitly defined target). See the `linkTarget` modifier + /// for more information. + static var linkTarget: Self { .init() } +} + +public extension EnvironmentKey where Value == HTMLListStyle { + /// Key used to define a style for `List` components. The default value uses + /// the `unordered` style (which produces `