diff --git a/Sources/HTMLKit/Framework/Environment/Condition.swift b/Sources/HTMLKit/Framework/Environment/Condition.swift new file mode 100644 index 00000000..2fa8c671 --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Condition.swift @@ -0,0 +1,42 @@ +/// A type representing a conditional that compares an environment value against another value +@_documentation(visibility: internal) +public struct Condition { + + /// A enumeration of potential comparison + public enum Comparison { + + /// Indicates an equal comparison + case equal + + /// Indicates a not-equal comparison + case unequal + + /// Indicates a greater-than comparison + case greater + + /// Indicates a less-than comparison + case less + } + + /// The left-hand side value + internal let lhs: EnvironmentValue + + /// The right-hand side value to test against + internal let rhs: any Comparable + + /// The comparison to perfom + internal let comparison: Comparison + + /// Initializes a condition + /// + /// - Parameters: + /// - lhs: The origin value + /// - rhs: The value to atest against + /// - operation: The comparison to perfom + public init(lhs: EnvironmentValue, rhs: any Comparable, comparison: Comparison) { + + self.lhs = lhs + self.rhs = rhs + self.comparison = comparison + } +} diff --git a/Sources/HTMLKit/Framework/Environment/Conditional.swift b/Sources/HTMLKit/Framework/Environment/Conditional.swift new file mode 100644 index 00000000..d8904f7b --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Conditional.swift @@ -0,0 +1,19 @@ +/// A type that defines a conditonal value which will be evualuated by the renderer. +@_documentation(visibility: internal) +public indirect enum Conditional { + + /// Holds an optional + case optional(EnvironmentValue) + + /// Holds an relation + case relation(Relation) + + /// Holds a condition + case condition(Condition) + + /// Holds a negation + case negation(Negation) + + /// Holds a value + case value(EnvironmentValue) +} diff --git a/Sources/HTMLKit/Framework/Environment/Environment.swift b/Sources/HTMLKit/Framework/Environment/Environment.swift index 32f89fac..0e35029d 100644 --- a/Sources/HTMLKit/Framework/Environment/Environment.swift +++ b/Sources/HTMLKit/Framework/Environment/Environment.swift @@ -1,41 +1,367 @@ import Foundation -/// A type that represents the environment -public class Environment { +/// A class that represents the environment +/// +/// The environment provides storage for various settings used by the renderer +@_documentation(visibility: internal) +public final class Environment { + + /// An enumeration of possible rendering errors. + public enum Errors: Error { + + /// Indicates a casting error. + case unableToCastEnvironmentValue + + /// Indicates a wrong environment key. + case unindendedEnvironmentKey + + /// Indicates a missing environment object. + case environmentObjectNotFound + + /// Indicates a missing environment value. + case environmentValueNotFound + + /// A brief error description. + public var description: String { + + switch self { + case .unableToCastEnvironmentValue: + return "Unable to cast the environment value." + + case .unindendedEnvironmentKey: + return "The environment key is not indended." + + case .environmentValueNotFound: + return "Unable to retrieve environment value." + + case .environmentObjectNotFound: + return "Unable to retrieve environment object." + } + } + } /// The storage of the environment private var storage: [AnyKeyPath: Any] - /// Initiates a manager + /// Initializes the environment public init() { self.storage = [:] } - /// The current time zone of the environment - public var timeZone: TimeZone? + /// The current time zone of the environment + public var timeZone: TimeZone? { + + get { + retrieve(for: \EnvironmentKeys.timeZone) as? TimeZone + } + } - /// The current calender of the environment - public var calendar: Calendar? + /// The current calendar of the environment + public var calendar: Calendar? { + + get { + retrieve(for: \EnvironmentKeys.calendar) as? Calendar + } + } - /// The current local of the environment - public var locale: Locale? + /// The current locale of the environment + public var locale: Locale? { + + get { + retrieve(for: \EnvironmentKeys.locale) as? Locale + } + } /// The current color scheme of the environment - public var colorScheme: String? + public var colorScheme: String? { + + get { + retrieve(for: \EnvironmentKeys.colorScheme) as? String + } + } - /// Retrieves an item from storage by its path + /// Retrieves a value from environment for a given key path + /// + /// - Parameter path: The key path used to look up the value + /// + /// - Returns: The value public func retrieve(for path: AnyKeyPath) -> Any? { + return storage[path] + } + + /// Inserts or updates a value in the environment for the given key path + /// + /// - Parameters: + /// - value: The value to be stored or updated + /// - path: The key path that identifies where the value is stored + public func upsert(_ value: T, for path: AnyKeyPath) { + storage[path] = value + } + + /// Resolves an environment value + /// + /// - Parameter value: The environment value to resolve + /// + /// - Returns: The resolved environment value + internal func resolve(value: EnvironmentValue) throws -> Any { - if let value = self.storage[path] { - return value + guard let parent = retrieve(for: value.parentPath) else { + throw Errors.environmentObjectNotFound + } + + guard let value = parent[keyPath: value.valuePath] else { + throw Errors.environmentValueNotFound } - return nil + return value } - /// Adds und updates an item to the storage - public func upsert(_ value: T, for path: AnyKeyPath) { - self.storage[path] = value + /// Evaluates an environment value + /// + /// - Parameter value: The value to evaluate + /// + /// - Returns: The result of evaluation + internal func evaluate(value: EnvironmentValue) throws -> Bool { + + guard let boolValue = try resolve(value: value) as? Bool else { + throw Errors.unableToCastEnvironmentValue + } + + return boolValue + } + + /// Evaluates an environment relation + /// + /// - Parameter relation: The relation to evaluate + /// + /// - Returns: The result of the evaluation + internal func evaluate(relation: Relation) throws -> Bool { + + switch relation.term { + case .conjunction: + + var result = true + + switch relation.lhs { + case .optional(let optional): + result = try evaluate(optional: optional) + + case .condition(let condition): + result = try evaluate(condition: condition) + + case .relation(let relation): + result = try evaluate(relation: relation) + + case .negation(let negation): + result = try evaluate(negation: negation) + + case .value(let value): + result = try evaluate(value: value) + } + + if !result { + // Bail early if the first result already is false + return result + } + + switch relation.rhs { + case .optional(let optional): + result = try evaluate(optional: optional) + + case .condition(let condition): + result = try evaluate(condition: condition) + + case .relation(let relation): + result = try evaluate(relation: relation) + + case .negation(let negation): + result = try evaluate(negation: negation) + + case .value(let value): + result = try evaluate(value: value) + } + + return result + + case .disjunction: + + var result = false + + switch relation.lhs { + case .optional(let optional): + result = try evaluate(optional: optional) + + case .condition(let condition): + result = try evaluate(condition: condition) + + case .relation(let relation): + result = try evaluate(relation: relation) + + case .negation(let negation): + result = try evaluate(negation: negation) + + case .value(let value): + result = try evaluate(value: value) + } + + if result { + // Bail early if the first result is already true + return result + } + + switch relation.rhs { + case .optional(let optional): + result = try evaluate(optional: optional) + + case .condition(let condition): + result = try evaluate(condition: condition) + + case .relation(let relation): + result = try evaluate(relation: relation) + + case .negation(let negation): + result = try evaluate(negation: negation) + + case .value(let value): + result = try evaluate(value: value) + } + + return result + } + } + + /// Evaluates an environment condition + /// + /// - Parameter condition: The condition to evaluate + /// + /// - Returns: The result of the evaluation + internal func evaluate(condition: Condition) throws -> Bool { + + let value = try resolve(value: condition.lhs) + + switch condition.comparison { + case .equal: + return condition.rhs.equal(value) + + case .greater: + return condition.rhs.greater(value) + + case .unequal: + return condition.rhs.unequal(value) + + case .less: + return condition.rhs.less(value) + } + } + + /// Evaluates an environment negation + /// + /// - Parameter negation: The negation to evaluate + /// + /// - Returns: The result of the evaluation + internal func evaluate(negation: Negation) throws -> Bool { + return try !evaluate(value: negation.value) + } + + /// Evaluates an environment optional + /// + /// - Parameter optional: The optional to evaluate + /// + /// - Returns: The result of the evaluation + internal func evaluate(optional: EnvironmentValue) throws -> Bool { + + guard let optionalValue = try resolve(value: optional) as? Nullable else { + throw Errors.unableToCastEnvironmentValue + } + + if !optionalValue.isNull { + return true + } + + return false + } +} + +extension Environment { + + /// Evaluates one condition + /// + /// - Parameters: + /// - condition: The condition to evaluate + /// - content: The content for the true statement + /// + /// - Returns: A environment statement + public static func check(_ condition: EnvironmentValue, @ContentBuilder content: () -> [Content]) -> Statement { + return Statement(compound: .value(condition), first: content(), second: []) + } + + /// Evaluates one condition + /// + /// - Parameters: + /// - condition: The condition to evaluate + /// - content: The content for the true statement + /// - then: The content for the false statement + /// + /// - Returns: A environment statement + public static func check(_ condition: EnvironmentValue, @ContentBuilder content: () -> [Content], @ContentBuilder then: () -> [Content]) -> Statement { + return Statement(compound: .value(condition), first: content(), second: then()) + } + + /// Evaluates one condition + /// + /// - Parameters: + /// - condition: The condition to evaluate + /// - content: The content for the true statement + /// + /// - Returns: A environment statement + public static func check(_ condition: Conditional, @ContentBuilder content: () -> [Content]) -> Statement { + return Statement(compound: condition, first: content(), second: []) + } + + /// Evaluates one condition + /// + /// - Parameters: + /// - condition: The condition to evaluate + /// - content: The content for the true statement + /// - then: The content for the false statement + /// + /// - Returns: A environment statement + public static func check(_ condition: Conditional, @ContentBuilder content: () -> [Content], @ContentBuilder then: () -> [Content]) -> Statement { + return Statement(compound: condition, first: content(), second: then()) + } + + /// Unwraps a optional environment value + /// + /// - Parameters: + /// - value: The optional value to unwrap + /// - content: The content for some statement + /// + /// - Returns: A environment statement + public static func unwrap(_ value: EnvironmentValue, @ContentBuilder content: (EnvironmentValue) -> [Content]) -> Statement { + return Statement(compound: .optional(value), first: content(value), second: []) + } + + /// Unwraps a optional environment value + /// + /// - Parameters: + /// - value: The optional value to unwrap + /// - content: The content for some statement + /// - then: The content for none statement + /// + /// - Returns: A environment statement + public static func unwrap(_ value: EnvironmentValue, @ContentBuilder content: (EnvironmentValue) -> [Content], @ContentBuilder then: () -> [Content]) -> Statement { + return Statement(compound: .optional(value), first: content(value), second: then()) + } + + /// Iterates through a sequence of values + /// + /// - Parameters: + /// - sequence: The sequence to iterate over + /// - content: The content for the iteration + /// + /// - Returns: A environment sequence + public static func loop(_ sequence: EnvironmentValue, @ContentBuilder content: (EnvironmentValue) -> [Content]) -> Sequence { + return Sequence(value: sequence, content: content(sequence)) } } diff --git a/Sources/HTMLKit/Framework/Environment/EnvironmentKeys.swift b/Sources/HTMLKit/Framework/Environment/EnvironmentKeys.swift index df68b701..39c2154a 100644 --- a/Sources/HTMLKit/Framework/Environment/EnvironmentKeys.swift +++ b/Sources/HTMLKit/Framework/Environment/EnvironmentKeys.swift @@ -1,12 +1,19 @@ import Foundation +/// A set of predefined environment keys +/// +/// The keys are used on the environment modifiers to configure various settings for the environment. public struct EnvironmentKeys: Hashable { - public var calender: Calendar + /// A key used to configure the environment's calendar + public var calendar: Calendar + /// A key used to configure the environment's time zone public var timeZone: TimeZone + /// A key used to configure the environment's locale public var locale: Locale + /// A key used to configure the environment's color scheme public var colorScheme: String } diff --git a/Sources/HTMLKit/Framework/Environment/EnvironmentModifier.swift b/Sources/HTMLKit/Framework/Environment/EnvironmentModifier.swift index 06a8aab6..46438175 100644 --- a/Sources/HTMLKit/Framework/Environment/EnvironmentModifier.swift +++ b/Sources/HTMLKit/Framework/Environment/EnvironmentModifier.swift @@ -1,22 +1,24 @@ -/* - Abstract: - The file contains the environment modifier. - */ - -/// A type that contains the value and the following content, after modifing the environment. +/// A type that holds the modification details for the environment +/// +/// The modifier is received by the renderer and applied to the environment. @_documentation(visibility: internal) public struct EnvironmentModifier: Content { /// The environment key - public var key: AnyKeyPath + internal let key: AnyKeyPath /// The environment value - public var value: Any? + internal let value: Any? - /// The following content - public var content: [Content] + /// The sub-content + internal let content: [Content] - /// Initiates a environment modifier + /// Initializes an environment modifier + /// + /// - Parameters: + /// - key: The key path of the environment value to be modified + /// - value: The new value to update the environment value with + /// - content: The sub-content to be rendered public init(key: AnyKeyPath, value: Any? = nil, content: [Content]) { self.key = key diff --git a/Sources/HTMLKit/Framework/Environment/EnvironmentObject.swift b/Sources/HTMLKit/Framework/Environment/EnvironmentObject.swift index d030f9ce..f47aa845 100644 --- a/Sources/HTMLKit/Framework/Environment/EnvironmentObject.swift +++ b/Sources/HTMLKit/Framework/Environment/EnvironmentObject.swift @@ -1,18 +1,20 @@ import Foundation -/// A property wrapper type to initate an environment object +/// A property wrapper type to provide access to an environment object +/// +/// An environment object allows to read shared environment data. @frozen @propertyWrapper public struct EnvironmentObject { - /// The wrapped value + /// The wrapped object public var wrappedValue: Wrapper - /// Converts the type into the wrapped value + /// Initialiizes the environment object public init(_ type: ObjectType.Type) { self.wrappedValue = .init() } - /// A type, that holds the environment object informationen + /// A wrapper, that holds the object informationen @dynamicMemberLookup public struct Wrapper { /// The path of the parent @@ -21,20 +23,26 @@ import Foundation /// The path of the value internal var path: AnyKeyPath - /// Initiates a wrapper + /// Initializes the wrapper public init() { self.path = \WrapperType.self } - /// Initiates a wrapper with the necessary information for the environment object + /// Initializes the wrapper + /// + /// - Parameters: + /// - parent: The path of the parent + /// - path: The path of the value internal init(parent: AnyKeyPath, path: AnyKeyPath) { self.parent = parent self.path = path } - /// Looks up for a containing property + /// Accesses a wrapped value for a given key path dynamically + /// + /// - Returns: An environment value public subscript(dynamicMember member: KeyPath) -> EnvironmentValue { guard let newPath = self.path.appending(path: member) else { @@ -48,7 +56,9 @@ import Foundation return .init(parentPath: self.path, valuePath: newPath) } - /// Looks up for a containing model + /// Accesses a wrapped model object for a given key path dynamically + /// + /// - Returns: An environment value public subscript(dynamicMember member: KeyPath) -> Wrapper where T: ViewModel { guard let newPath = self.path.appending(path: member) else { diff --git a/Sources/HTMLKit/Framework/Environment/EnvironmentString.swift b/Sources/HTMLKit/Framework/Environment/EnvironmentString.swift new file mode 100644 index 00000000..c80b5840 --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/EnvironmentString.swift @@ -0,0 +1,31 @@ +/// A type that represents an environment string +@_documentation(visibility: internal) +public struct EnvironmentString: ExpressibleByStringLiteral, ExpressibleByStringInterpolation, Content { + + internal var values: [Content] = [] + + public init(stringLiteral: String) { + self.values.append(stringLiteral) + } + + public init(stringInterpolation: StringInterpolation) { + self.values.append(contentsOf: stringInterpolation.values) + } + + public struct StringInterpolation: StringInterpolationProtocol { + + internal var values: [Content] = [] + + public init(literalCapacity: Int, interpolationCount: Int) { + values.reserveCapacity(interpolationCount) + } + + public mutating func appendLiteral(_ literal: String) { + values.append(literal) + } + + public mutating func appendInterpolation(_ env: EnvironmentValue) { + values.append(env) + } + } +} diff --git a/Sources/HTMLKit/Framework/Environment/EnvironmentValue.swift b/Sources/HTMLKit/Framework/Environment/EnvironmentValue.swift index e9022c67..06afbc0f 100644 --- a/Sources/HTMLKit/Framework/Environment/EnvironmentValue.swift +++ b/Sources/HTMLKit/Framework/Environment/EnvironmentValue.swift @@ -1,16 +1,23 @@ import Foundation -/// A type, that acts as a binding value +/// A type that serves as a placeholder for an environment value +/// +/// The placeholder will be evaluated and resolved by the renderer when needed. +@_documentation(visibility: internal) public struct EnvironmentValue: Content { /// The path of the values parent - internal var parentPath: AnyKeyPath + internal let parentPath: AnyKeyPath /// The path of the value - internal var valuePath: AnyKeyPath + internal let valuePath: AnyKeyPath - /// Initiates a environment value - public init(parentPath: AnyKeyPath, valuePath: AnyKeyPath) { + /// Initializes a environment value + /// + /// - Parameters: + /// - parentPath: The key path of the parent + /// - valuePath: The key path of the value + internal init(parentPath: AnyKeyPath, valuePath: AnyKeyPath) { self.parentPath = parentPath self.valuePath = valuePath @@ -19,7 +26,36 @@ public struct EnvironmentValue: Content { extension EnvironmentValue { - static public func + (lhs: Content, rhs: Self) -> Content { + /// Concat environment value with environment value + public static func + (lhs: Content, rhs: Self) -> Content { return [lhs, rhs] } + + /// Compare an environment value with another comparable value + /// + /// Makes an unequal evaluation + public static func != (lhs: Self, rhs: some Comparable) -> Conditional { + return .condition(Condition(lhs: lhs, rhs: rhs, comparison: .unequal)) + } + + /// Compare an environment value with another comparable value + /// + /// Makes an equal evaluation + public static func == (lhs: Self, rhs: some Comparable) -> Conditional { + return .condition(Condition(lhs: lhs, rhs: rhs, comparison: .equal)) + } + + /// Compare an environment value with another comparable value + /// + /// Makes an less than evaluation + public static func < (lhs: Self, rhs: some Comparable) -> Conditional { + return .condition(Condition(lhs: lhs, rhs: rhs, comparison: .less)) + } + + /// Compare an environment value with another comparable value + /// + /// Makes an greater than evaluation + public static func > (lhs: Self, rhs: some Comparable) -> Conditional { + return .condition(Condition(lhs: lhs, rhs: rhs, comparison: .greater)) + } } diff --git a/Sources/HTMLKit/Framework/Environment/Negation.swift b/Sources/HTMLKit/Framework/Environment/Negation.swift new file mode 100644 index 00000000..042cad6f --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Negation.swift @@ -0,0 +1,30 @@ +/// A type thats represents an invert conditional +@_documentation(visibility: internal) +public struct Negation { + + /// The left-hand side conditional + internal let value: EnvironmentValue + + /// Initializes the negation + /// + /// - Parameter lhs: The conditional to evaluate + public init(value: EnvironmentValue) { + + self.value = value + } +} + +/// Creates a invert conditional +/// +/// ```swift +/// Environment.when(!value) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// +/// - Returns: A invert conditional +public prefix func ! (value: EnvironmentValue) -> Conditional { + return .negation(Negation(value: value)) +} diff --git a/Sources/HTMLKit/Framework/Environment/Nullable.swift b/Sources/HTMLKit/Framework/Environment/Nullable.swift new file mode 100644 index 00000000..4ef47815 --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Nullable.swift @@ -0,0 +1,9 @@ +/// A type that represent a nullable value. +/// +/// > Note: This protocol is intended as a temporary workaround. +@_documentation(visibility: internal) +internal protocol Nullable { + + /// Checks whether the value is absent without needing to know the underlying type. + var isNull: Bool { get } +} diff --git a/Sources/HTMLKit/Framework/Environment/Relation.swift b/Sources/HTMLKit/Framework/Environment/Relation.swift new file mode 100644 index 00000000..fc78dc44 --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Relation.swift @@ -0,0 +1,138 @@ +/// A type representing the logical relation between two conditionals +@_documentation(visibility: internal) +public struct Relation { + + /// A enumeration of potential logical terms + public enum Term { + + /// Indicates a conjunction + /// + /// All conditions must be true for the relation to be true + case conjunction + + /// Indicates a disjunction + /// + /// One condition must be at least true for the relation to be true + case disjunction + } + + /// The logical term specifying the relation + internal let term: Term + + /// The left-hand side conditional + internal let lhs: Conditional + + /// The right-hand side conditional + internal let rhs: Conditional + + /// Initializes a relation + /// + /// - Parameters: + /// - term: The term on which the relation acts on + /// - lhs: The left-hand side conditional + /// - rhs: The right-hand side conditional to test against + public init(term: Term, lhs: Conditional, rhs: Conditional) { + + self.term = term + self.lhs = lhs + self.rhs = rhs + } +} + +/// Creates a conjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value > 0 && value) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: An environment value +/// +/// - Returns: A conjunctional relation +public func && (lhs: Conditional, rhs: EnvironmentValue) -> Conditional { + return .relation(Relation(term: .conjunction, lhs: lhs, rhs: .value(rhs))) +} + +/// Creates a conjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value && value > 0) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: An environment value +/// +/// - Returns: A conjunctional relation +public func && (lhs: EnvironmentValue, rhs: Conditional) -> Conditional { + return .relation(Relation(term: .conjunction, lhs: .value(lhs), rhs: rhs)) +} + + +/// Creates a conjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value > 0 && value < 2) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: The right-hand side conditional +/// +/// - Returns: A conjunctional relation +public func && (lhs: Conditional, rhs: Conditional) -> Conditional { + return .relation(Relation(term: .conjunction, lhs: lhs, rhs: rhs)) +} + +/// Creates a disjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value > 0 || value) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: The right-hand side conditional +/// +/// - Returns: A disjunctional relation +public func || (lhs: Conditional, rhs: EnvironmentValue) -> Conditional { + return .relation(Relation(term: .disjunction, lhs: lhs, rhs: .value(rhs))) +} + +/// Creates a disjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value || value > 0) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: The right-hand side conditional +/// +/// - Returns: A disjunctional relation +public func || (lhs: EnvironmentValue, rhs: Conditional) -> Conditional { + return .relation(Relation(term: .disjunction, lhs: .value(lhs), rhs: rhs)) +} + +/// Creates a disjunctional relation between two conditionals +/// +/// ```swift +/// Environment.when(value > 0 || value < 2) { +/// } +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand side conditional +/// - rhs: The right-hand side conditional +/// +/// - Returns: A disjunctional relation +public func || (lhs: Conditional, rhs: Conditional) -> Conditional { + return .relation(Relation(term: .disjunction, lhs: lhs, rhs: rhs)) +} + diff --git a/Sources/HTMLKit/Framework/Environment/Sequence.swift b/Sources/HTMLKit/Framework/Environment/Sequence.swift new file mode 100644 index 00000000..f591ec81 --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Sequence.swift @@ -0,0 +1,21 @@ +/// A type representing a loop rendering content within the environment +@_documentation(visibility: internal) +public struct Sequence: GlobalElement { + + /// The environment value of the sequence + internal let value: EnvironmentValue + + /// The accumulated content + internal let content: [Content] + + /// Initializes a loop + /// + /// - Parameters: + /// - value: The environment value to retrieve the sequence from + /// - content: The content to render for each item in the sequence + public init(value: EnvironmentValue, content: [Content]) { + + self.value = value + self.content = content + } +} diff --git a/Sources/HTMLKit/Framework/Environment/Statement.swift b/Sources/HTMLKit/Framework/Environment/Statement.swift new file mode 100644 index 00000000..2db7286e --- /dev/null +++ b/Sources/HTMLKit/Framework/Environment/Statement.swift @@ -0,0 +1,26 @@ +/// A type representing a conditional block within the environment +@_documentation(visibility: internal) +public struct Statement: GlobalElement { + + /// The compound condition + internal let compound: Conditional + + /// The first statement + internal let first: [Content] + + /// The second statement + internal let second: [Content] + + /// Initializes a statement + /// + /// - Parameters: + /// - compound: The compound of conditionals + /// - first: The statement to execute if conditionals are true + /// - second: The statement to execute if conditionals are false + public init(compound: Conditional, first: [Content], second: [Content]) { + + self.compound = compound + self.first = first + self.second = second + } +} diff --git a/Sources/HTMLKit/Framework/Extensions/Comparable+HTMLKit.swift b/Sources/HTMLKit/Framework/Extensions/Comparable+HTMLKit.swift new file mode 100644 index 00000000..ef966e0f --- /dev/null +++ b/Sources/HTMLKit/Framework/Extensions/Comparable+HTMLKit.swift @@ -0,0 +1,52 @@ +extension Comparable { + + /// Checks for equality + /// + /// - Parameters: + /// - other: The other value to compare + /// + /// - Returns: The result + internal func equal(_ other: Any) -> Bool { + + guard let other = other as? Self else { + return false + } + + return other == self + } + + /// Checks for inequality + /// + /// - Parameters: + /// - other: The other value to compare + /// + /// - Returns: The result + internal func unequal(_ other: Any) -> Bool { + return !equal(other) + } + + /// Checks for a greater value + /// + /// - Parameters: + /// - other: The other value to compare + /// + /// - Returns: The result + internal func greater(_ other: Any) -> Bool { + + guard let other = other as? Self else { + return false + } + + return other > self + } + + /// Checks for smaller value + /// + /// - Parameters: + /// - other: The other value to compare + /// + /// - Returns: The result + internal func less(_ other: Any) -> Bool { + return !greater(other) + } +} diff --git a/Sources/HTMLKit/Framework/Extensions/Datatypes+Content.swift b/Sources/HTMLKit/Framework/Extensions/Datatypes+Content.swift index b5444a21..b20c7978 100644 --- a/Sources/HTMLKit/Framework/Extensions/Datatypes+Content.swift +++ b/Sources/HTMLKit/Framework/Extensions/Datatypes+Content.swift @@ -15,8 +15,6 @@ extension Float: Content {} extension Int: Content {} -extension Optional: Content{} - extension String: Content { static public func + (lhs: Content, rhs: Self) -> Content { diff --git a/Sources/HTMLKit/Framework/Extensions/Optional+HTMLKit.swift b/Sources/HTMLKit/Framework/Extensions/Optional+HTMLKit.swift new file mode 100644 index 00000000..d03bc9c4 --- /dev/null +++ b/Sources/HTMLKit/Framework/Extensions/Optional+HTMLKit.swift @@ -0,0 +1,9 @@ +extension Optional: Nullable { + + internal var isNull: Bool { + return self == nil + } +} + +extension Optional: Content { +} diff --git a/Sources/HTMLKit/Framework/Primitives/Elements/Element.swift b/Sources/HTMLKit/Framework/Primitives/Elements/Element.swift index 106e569c..e678ff83 100644 --- a/Sources/HTMLKit/Framework/Primitives/Elements/Element.swift +++ b/Sources/HTMLKit/Framework/Primitives/Elements/Element.swift @@ -1,8 +1,3 @@ -/* - Abstract: - The file contains the default definition of an element. It defines which properties and methods an element should come with. - */ - /// A type that represents any html-element. @_documentation(visibility: internal) public protocol Element: Content { @@ -10,14 +5,31 @@ public protocol Element: Content { extension Element { + /// Sets the environment for the sub-content + /// + /// - Parameter key: The key + /// + /// - Returns: The environment modifier public func environment(key: KeyPath) -> EnvironmentModifier { return .init(key: key, content: [self]) } + /// Supplies a value to the environment + /// + /// - Parameters: + /// - key: The key to store the value with + /// - value: The value to be stored + /// + /// - Returns: The environment modifier public func environment(key: KeyPath, value: V) -> EnvironmentModifier { return .init(key: key, value: value, content: [self]) } + /// Supplies the object to the environment + /// + /// - Parameter object: The object to be stored + /// + /// - Returns: The environment modifier public func environment(object: T) -> EnvironmentModifier { return .init(key: \T.self, value: object, content: [self]) } diff --git a/Sources/HTMLKit/Framework/Rendering/Renderer.swift b/Sources/HTMLKit/Framework/Rendering/Renderer.swift index 56ba9c6a..503f28c6 100644 --- a/Sources/HTMLKit/Framework/Rendering/Renderer.swift +++ b/Sources/HTMLKit/Framework/Rendering/Renderer.swift @@ -9,40 +9,6 @@ import Logging @_documentation(visibility: internal) public final class Renderer { - - /// An enumeration of possible rendering errors. - public enum Errors: Error { - - /// Indicates a casting error. - case unableToCastEnvironmentValue - - /// Indicates a wrong environment key. - case unindendedEnvironmentKey - - /// Indicates a missing environment object. - case environmentObjectNotFound - - /// Indicates a missing environment value. - case environmentValueNotFound - - /// A brief error description. - public var description: String { - - switch self { - case .unableToCastEnvironmentValue: - return "Unable to cast the environment value." - - case .unindendedEnvironmentKey: - return "The environment key is not indended." - - case .environmentValueNotFound: - return "Unable to retrieve environment value." - - case .environmentObjectNotFound: - return "Unable to retrieve environment object." - } - } - } /// The context environment private var environment: Environment @@ -136,6 +102,14 @@ public final class Renderer { result += escape(content: try render(value: value)) } + if let statement = content as? Statement { + result += try render(statement: statement) + } + + if let loop = content as? Sequence { + result += try render(loop: loop) + } + if let string = content as? MarkdownString { if !features.contains(.markdown) { @@ -146,6 +120,10 @@ public final class Renderer { } } + if let envstring = content as? EnvironmentString { + result += try render(envstring: envstring) + } + if let element = content as? String { result += escape(content: element) } @@ -211,6 +189,14 @@ public final class Renderer { result += escape(content: try render(value: value)) } + if let loop = content as? Sequence { + result += try render(loop: loop) + } + + if let statement = content as? Statement { + result += try render(statement: statement) + } + if let string = content as? MarkdownString { if !features.contains(.markdown) { @@ -221,6 +207,10 @@ public final class Renderer { } } + if let envstring = content as? EnvironmentString { + result += try render(envstring: envstring) + } + if let element = content as? String { result += escape(content: element) } @@ -329,6 +319,14 @@ public final class Renderer { result += escape(content: try render(value: value)) } + if let statement = content as? Statement { + result += try render(statement: statement) + } + + if let loop = content as? Sequence { + result += try render(loop: loop) + } + if let string = content as? MarkdownString { if !features.contains(.markdown) { @@ -339,6 +337,10 @@ public final class Renderer { } } + if let envstring = content as? EnvironmentString { + result += try render(envstring: envstring) + } + if let element = content as? String { result += escape(content: element) } @@ -386,31 +388,6 @@ public final class Renderer { if let value = modifier.value { self.environment.upsert(value, for: modifier.key) - - } else { - - if let value = self.environment.retrieve(for: modifier.key) { - - if let key = modifier.key as? PartialKeyPath { - - switch key { - case \.locale: - self.environment.locale = value as? Locale - - case \.calender: - self.environment.calendar = value as? Calendar - - case \.timeZone: - self.environment.timeZone = value as? TimeZone - - case \.colorScheme: - self.environment.colorScheme = value as? String - - default: - throw Errors.unindendedEnvironmentKey - } - } - } } return try render(contents: modifier.content) @@ -419,13 +396,7 @@ public final class Renderer { /// Renders a environment value. private func render(value: EnvironmentValue) throws -> String { - guard let parent = self.environment.retrieve(for: value.parentPath) else { - throw Errors.environmentObjectNotFound - } - - guard let value = parent[keyPath: value.valuePath] else { - throw Errors.environmentValueNotFound - } + let value = try self.environment.resolve(value: value) switch value { case let floatValue as Float: @@ -450,9 +421,42 @@ public final class Renderer { return formatter.string(from: dateValue) default: - throw Errors.unableToCastEnvironmentValue + throw Environment.Errors.unableToCastEnvironmentValue } } + + /// Renders a environment statement + /// + /// - Parameter statement: The statement to resolve + /// + /// - Returns: The rendered condition + private func render(statement: Statement) throws -> String { + + var result = false + + switch statement.compound { + case .optional(let optional): + result = try environment.evaluate(optional: optional) + + case .value(let value): + result = try environment.evaluate(value: value) + + case .condition(let condition): + result = try environment.evaluate(condition: condition) + + case .negation(let negation): + result = try environment.evaluate(negation: negation) + + case .relation(let relation): + result = try environment.evaluate(relation: relation) + } + + if result { + return try render(contents: statement.first) + } + + return try render(contents: statement.second) + } /// Renders the node attributes. private func render(attributes: OrderedDictionary) throws -> String { @@ -481,6 +485,11 @@ public final class Renderer { return self.markdown.render(string: escape(content: markdown.raw)) } + /// Renders a environment interpolation + private func render(envstring: EnvironmentString) throws -> String { + return try render(contents: envstring.values) + } + /// Converts specific charaters into encoded values. private func escape(attribute value: String) -> String { @@ -503,4 +512,152 @@ public final class Renderer { return value } + + /// Renders an environment loop + /// + /// - Parameter loop: The loop to resolve + /// + /// - Returns: The rendered loop + private func render(loop: Sequence) throws -> String { + + let value = try environment.resolve(value: loop.value) + + guard let sequence = value as? (any Swift.Sequence) else { + throw Environment.Errors.unableToCastEnvironmentValue + } + + var result = "" + + for value in sequence { + try render(loop: loop.content, with: value, on: &result) + } + + return result + } + + /// Renders the content within an environment loop + /// + /// - Parameters: + /// - contents: The content to render + /// - value: The value to resolve the environment value with + /// - result: The rendered content + private func render(loop contents: [Content], with value: Any, on result: inout String) throws { + + for content in contents { + + if let element = content as? (any ContentNode) { + try render(loop: element, with: value, on: &result) + } + + if let element = content as? (any CustomNode) { + try render(loop: element, with: value, on: &result) + } + + if let element = content as? (any EmptyNode) { + result += try render(element: element) + } + + if let element = content as? (any CommentNode) { + result += render(element: element) + } + + if let string = content as? LocalizedString { + result += try render(localized: string) + } + + if let string = content as? MarkdownString { + + if !features.contains(.markdown) { + result += escape(content: string.raw) + + } else { + result += try render(markdown: string) + } + } + + if let envstring = content as? EnvironmentString { + result += try render(envstring: envstring) + } + + if let element = content as? String { + result += escape(content: element) + } + + if content is EnvironmentValue { + + switch value { + case let floatValue as Float: + result += String(floatValue) + + case let intValue as Int: + result += String(intValue) + + case let doubleValue as Double: + result += String(doubleValue) + + case let stringValue as String: + result += stringValue + + default: + break + } + } + } + } + + /// Renders a content element within an environment loop + /// + /// - Parameters: + /// - element: The element to render + /// - value: The value to resolve the environment value with + /// - result: The result + private func render(loop element: some ContentNode, with value: Any, on result: inout String) throws { + + result += "<\(element.name)" + + if let attributes = element.attributes { + result += try render(attributes: attributes) + } + + result += ">" + + if let contents = element.content as? [Content] { + try render(loop: contents, with: value, on: &result) + } + + result += "" + } + + /// Renders a custom element within an environment loop + /// + /// - Parameters: + /// - element: The element to render + /// - value: The value to resolve the environment value with + /// - result: The rendered content + private func render(loop element: some CustomNode, with value: Any, on result: inout String) throws { + + result += "<\(element.name)" + + if let attributes = element.attributes { + result += try render(attributes: attributes) + } + + result += ">" + + if let contents = element.content as? [Content] { + try render(loop: contents, with: value, on: &result) + } + + result += "" + } + + /// Renders an environment string within a environment loop + /// + /// - Parameters: + /// - envstring: The environment string to render + /// - value: The raw value to resolve the environment value with + /// - result: The result + private func render(loop envstring: EnvironmentString, with value: Any, on result: inout String) throws { + try render(loop: envstring.values, with: value, on: &result) + } } diff --git a/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift b/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift index fd8f5bfa..311adc2c 100644 --- a/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift +++ b/Sources/HTMLKitVapor/Extensions/Vapor+HTMLKit.swift @@ -112,14 +112,14 @@ extension Request { public var htmlkit: ViewRenderer { if let acceptLanguage = self.acceptLanguage { - self.application.htmlkit.environment.locale = HTMLKit.Locale(tag: acceptLanguage) + self.application.htmlkit.environment.upsert(HTMLKit.Locale(tag: acceptLanguage), for: \HTMLKit.EnvironmentKeys.locale) } return .init(eventLoop: self.eventLoop, configuration: self.application.htmlkit.configuration, logger: self.logger) } } -extension HTMLKit.Renderer.Errors: AbortError { +extension HTMLKit.Environment.Errors: AbortError { @_implements(AbortError, reason) public var abortReason: String { self.description } @@ -127,7 +127,7 @@ extension HTMLKit.Renderer.Errors: AbortError { public var status: HTTPResponseStatus { .internalServerError } } -extension HTMLKit.Renderer.Errors: DebuggableError { +extension HTMLKit.Environment.Errors: DebuggableError { @_implements(DebuggableError, reason) public var debuggableReason: String { self.description } diff --git a/Tests/HTMLKitTests/EnvironmentTests.swift b/Tests/HTMLKitTests/EnvironmentTests.swift index 85176dd9..700cee9a 100644 --- a/Tests/HTMLKitTests/EnvironmentTests.swift +++ b/Tests/HTMLKitTests/EnvironmentTests.swift @@ -1,66 +1,416 @@ -/* - Abstract: - The file tests the annotations. - */ - import HTMLKit import XCTest final class EnvironmentTests: XCTestCase { - struct Object: Encodable { + var renderer = Renderer() + + /// Tests the environment access through the environment object + /// + /// The renderer is expected to evaluate the placeholder and renderer the resulting value. + func testEnvironmentAccess() throws { + + struct FamilyObject: ViewModel { + + let name = "Doe" + let father = FatherObject() + } + + struct FatherObject: ViewModel { + + let avatar = "john_doe.jpeg" + let name = "John" + } + + struct ParentView: View { + + let content: [Content] + + init(@ContentBuilder content: () -> [Content]) { + self.content = content() + } + + var body: Content { + Division { + content + } + .environment(object: FamilyObject()) + } + } + + struct ChildView: View { + + @EnvironmentObject(FamilyObject.self) + var object + + var body: Content { + ParentView { + Section{ + Image() + .source(object.father.avatar) + Heading1 { + object.name + } + Paragraph { + object.father.name + " " + object.name + } + } + } + } + } + + XCTAssertEqual(try renderer.render(view: ChildView()), + """ +
\ +
\ + \ +

Doe

\ +

John Doe

\ +
\ +
+ """ + ) + } + + /// Tests condtion evaluation for the environment + /// + /// The renderer is expected to evaluated the condition correctly and renderer the right statement based on the condition. + func testEnvironmentCondition() throws { + + struct TestObject: ViewModel { + + let firstName = "Jane" + let age = 40 + let loggedIn = true + } + + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + + // Should return true + Environment.check(object.loggedIn) { + "True" + } + + // Should return false + Environment.check(!object.loggedIn) { + "True" + } then: { + "False" + } + + // Should return false + Environment.check(object.firstName == "John") { + "True" + } then: { + "False" + } + + // The counter test, should return true + Environment.check(object.firstName == "Jane") { + "True" + } + + // Should return true + Environment.check(object.firstName != "John") { + "True" + } + + // The counter test, should return false + Environment.check(object.firstName != "Jane") { + "True" + } then: { + "False" + } + + // Should return false + Environment.check(object.age > 41) { + "True" + } then: { + "False" + } + + // The counter test, should return true + Environment.check(object.age > 39) { + "True" + } + + // Should return true + Environment.check(object.age < 41) { + "True" + } + + // The counter test, should return false + Environment.check(object.age < 39) { + "True" + } then: { + "False" + } + } + .environment(object: TestObject()) + } + } + + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

TrueFalseFalseTrueTrueFalseFalseTrueTrueFalse

+ """ + ) + } + + /// Tests the evaluation of a statement with a conjunctional relation + func testConditionConjuction() throws { + + struct TestObject: ViewModel { + + let age = 40 + } + + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + + // The relation is true, cause both conditions are true + Environment.check(object.age > 39 && object.age < 41) { + "True" + } then: { + "False" + } + + // The relation is false, cause both conditions are false + Environment.check(object.age < 39 && object.age > 41) { + "True" + } then: { + "False" + } + + // The relation is false, cause the first condition is false + Environment.check(object.age > 41 && object.age > 39) { + "True" + } then: { + "False" + } + + // The relation is false, cause the second condition is false + Environment.check(object.age > 39 && object.age > 41) { + "True" + } then: { + "False" + } + } + .environment(object: TestObject()) + } + } - var title: String = "Welcome to WWDC" - var name: String = "Mattes!" - var image: String = "wwdc.jpeg" + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

TrueFalseFalseFalse

+ """ + ) } - struct ParentView: View { + /// Tests the evaluation of a statement with a disjunctional relation + func testConditionDisjunction() throws { + + struct TestObject: ViewModel { + + let age = 40 + } + + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + + // The relation is true, cause the second condition is true + Environment.check(object.age > 41 || object.age == 40) { + "True" + } then: { + "False" + } + + // The relation is true, cause the first condition is true + Environment.check(object.age == 40 || object.age > 41) { + "True" + } then: { + "False" + } + + // The relation is false, cause all conditions are false + Environment.check(object.age == 50 || object.age > 41) { + "True" + } then: { + "False" + } + } + .environment(object: TestObject()) + } + } - var content: [Content] + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

TrueTrueFalse

+ """ + ) + } + + /// Tests the evaluation of various relation combinations + func testConditionConjunctionAndDisjunction() throws { - init(@ContentBuilder content: () -> [Content]) { - self.content = content() + struct TestObject: ViewModel { + + let age = 40 } - var body: Content { - Division { - content + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + + // The statement is true, cause the first relation is true + Environment.check(object.age == 40 || object.age > 41 && object.age < 39) { + "True" + } then: { + "False" + } + + // The statement is true, cause the second relation is true + Environment.check(object.age == 50 || object.age < 41 && object.age > 39) { + "True" + } then: { + "False" + } + } + .environment(object: TestObject()) } - .environment(object: Object()) } + + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

TrueTrue

+ """ + ) } - struct ChildView: View { + /// Tests the iteration over a sequence of environment values + func testEnvironmentLoop() throws { - @EnvironmentObject(Object.self) - var object + struct TestObject: ViewModel { + + let children = ["Janek", "Janet"] + } - var body: Content { - ParentView { - Section{ - Image() - .source(object.image) - Heading2 { - object.title + " " + object.name + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + Environment.loop(object.children) { child in + Paragraph { + child + } } } + .environment(object: TestObject()) } } + + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

\ +

Janek

\ +

Janet

\ +

+ """ + ) } - var renderer = Renderer() + /// Tests the conditional rendering based on an optional environment value + /// + /// The renderer is expected to evaluate the presence of the value and render the right content + /// accordingly. + func testEnvironmentUnwrap() throws { + + struct TestObject: ViewModel { + + let some: String? = "Some" + let none: String? = nil + } + + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + + // Should return none, cause the value is nil + Environment.unwrap(object.none) { value in + value + } then: { + "None" + } + + // Should return the value + Environment.unwrap(object.some) { value in + value + } + } + .environment(object: TestObject()) + } + } + + XCTAssertEqual(try renderer.render(view: TestView()), + """ +

NoneSome

+ """ + ) + } - func testEnvironment() throws { + /// Tests the string interpolation with an environment value + /// + /// The renderer is expected to render the string correctly + func testStringInterpolationWithEnvironment() throws { - XCTAssertEqual(try renderer.render(view: ChildView()), + struct TestObject: ViewModel { + + let name = "Jane" + } + + struct TestView: View { + + @EnvironmentObject(TestObject.self) + var object + + var body: Content { + Paragraph { + EnvironmentString("Hello, how are you \(object.name)?") + } + .environment(object: TestObject()) + } + } + + XCTAssertEqual(try renderer.render(view: TestView()), """ -
\ -
\ - \ -

Welcome to WWDC Mattes!

\ -
\ -
+

Hello, how are you Jane?

""" ) } diff --git a/Tests/HTMLKitVaporTests/ProviderTests.swift b/Tests/HTMLKitVaporTests/ProviderTests.swift index 517f46a8..5940ebcc 100644 --- a/Tests/HTMLKitVaporTests/ProviderTests.swift +++ b/Tests/HTMLKitVaporTests/ProviderTests.swift @@ -42,7 +42,6 @@ final class ProviderTests: XCTestCase { content } } - .environment(object: TestObject()) } } @@ -332,12 +331,17 @@ final class ProviderTests: XCTestCase { } } + /// Tests the access to environment through provider + /// + /// The provider is expected to recieve the environment object and resolve it based on the request. func testEnvironmentIntegration() throws { let app = Application(.testing) defer { app.shutdown() } + app.htmlkit.environment.upsert(TestObject(), for: \TestObject.self) + app.get("test") { request async throws -> Vapor.View in return try await request.htmlkit.render(TestPage.SipplingView()) } @@ -389,4 +393,76 @@ final class ProviderTests: XCTestCase { ) } } + + /// Tests the error reporting to Vapor for issues that may occur during environment access. + /// + /// The error is expected to be classified as an internal server error and includes a error message. + func testEnvironmentErrorReporting() throws { + + struct TestObject { + + let firstName = "Jane" + } + + struct UnkownObject: HTMLKit.View { + + @EnvironmentObject(TestObject.self) + var object + + var body: HTMLKit.Content { + Paragraph { + Environment.check(object.firstName) { + "True" + } + } + } + } + + struct WrongCast: HTMLKit.View { + + @EnvironmentObject(TestObject.self) + var object + + var body: HTMLKit.Content { + Paragraph { + Environment.check(object.firstName) { + "True" + } + } + .environment(object: TestObject()) + } + } + + let app = Application(.testing) + + defer { app.shutdown() } + + app.get("unkownobject") { request async throws -> Vapor.View in + + return try await request.htmlkit.render(UnkownObject()) + } + + app.get("wrongcast") { request async throws -> Vapor.View in + + return try await request.htmlkit.render(WrongCast()) + } + + try app.test(.GET, "unkownobject") { response in + + XCTAssertEqual(response.status, .internalServerError) + + let abort = try response.content.decode(AbortResponse.self) + + XCTAssertEqual(abort.reason, "Unable to retrieve environment object.") + } + + try app.test(.GET, "wrongcast") { response in + + XCTAssertEqual(response.status, .internalServerError) + + let abort = try response.content.decode(AbortResponse.self) + + XCTAssertEqual(abort.reason, "Unable to cast the environment value.") + } + } }