Skip to content

Commit

Permalink
Add a new Component-based API and a much more flexible rendering syst…
Browse files Browse the repository at this point in the history
…em (#61)

- Refactor Plot's rendering system to become much more flexible, by replacing the previous
   `ElementRenderer` class with a new, general-purpose `Renderer` struct, and by refactoring
   the `Node` enum into a struct that carries a free-form `rendering` closure. All of these changes
   are pure refactors - they don't affect the public API at all.
- Add a new `Component` API that enables HTML `<body>` components to be defined in a
   very SwiftUI-like way. The new API ships with lots of built-in implementations that map to
   commonly used elements (such as `div`, `a`, `ul`, and so on), and enables the API user to
   easily define their own components as well. It also features an environment API (similar to
   the one that SwiftUI offers), the ability to apply modifiers to components, and complete
   interoperability with the existing `Node`-based API.
- All of these changes are completely additive and fully backward compatible with the old API.
   The only potential situation that could cause these changes to be breaking for an API user is
   if a `Node` value was being switched on. Since it's now no longer an enum, that will no longer
   work, but it's highly unlikely that Plot's API has been used this way by any API user.
- Update the documentation for the new `Component` API.
  • Loading branch information
JohnSundell authored May 11, 2021
1 parent cc8bfb7 commit 80612b3
Show file tree
Hide file tree
Showing 43 changed files with 2,695 additions and 260 deletions.
223 changes: 198 additions & 25 deletions README.md

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions Sources/Plot/API/Attribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ public struct Attribute<Context> {
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
}
}
Expand All @@ -51,9 +58,8 @@ internal extension Attribute where Context == Any {
}
}

internal protocol AnyAttribute {
var name: String { get }
func render() -> String
extension Attribute: NodeConvertible {
public var node: Node<Context> { .attribute(self) }
}

extension Attribute: AnyAttribute {
Expand All @@ -65,9 +71,3 @@ extension Attribute: AnyAttribute {
return "\(name)=\"\(value)\""
}
}

extension Attribute: NodeConvertible {
func asNode() -> AnyNode {
Node<Context>.attribute(self)
}
}
144 changes: 144 additions & 0 deletions Sources/Plot/API/Component.swift
Original file line number Diff line number Diff line change
@@ -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<Any>(
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<T>(_ attribute: Attribute<T>) -> 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 overriden 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<T>(_ value: T, key: EnvironmentKey<T>) -> 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<T>(withContext context: T.Type = T.self) -> Node<T> {
.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)
}
}
93 changes: 93 additions & 0 deletions Sources/Plot/API/ComponentAttributes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* 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 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
/// `<a>` 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
/// `<a>` 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)
}
}
46 changes: 46 additions & 0 deletions Sources/Plot/API/ComponentBuilder.swift
Original file line number Diff line number Diff line change
@@ -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: [])
}
}
46 changes: 46 additions & 0 deletions Sources/Plot/API/ComponentContainer.swift
Original file line number Diff line number Diff line change
@@ -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<Any>.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<Any>.raw(html) }
}
}
Loading

0 comments on commit 80612b3

Please sign in to comment.