.raw(html) }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/Plot/Internal/String+Escaping.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | internal extension String {
8 | func escaped() -> String {
9 | var pendingAmpersandString: String?
10 |
11 | func flushPendingAmpersandString(
12 | withSuffix suffix: String? = nil,
13 | resettingTo newValue: String? = nil
14 | ) -> String {
15 | let pending = pendingAmpersandString
16 | pendingAmpersandString = newValue
17 | return pending.map { "&\($0)\(suffix ?? "")" } ?? suffix ?? ""
18 | }
19 |
20 | return String(flatMap { character -> String in
21 | switch character {
22 | case "<":
23 | return flushPendingAmpersandString(withSuffix: "<")
24 | case ">":
25 | return flushPendingAmpersandString(withSuffix: ">")
26 | case "&":
27 | return flushPendingAmpersandString(resettingTo: "")
28 | case ";":
29 | let pending = pendingAmpersandString.map { "&\($0);" }
30 | pendingAmpersandString = nil
31 | return pending ?? ";"
32 | case "#" where pendingAmpersandString?.isEmpty == true:
33 | pendingAmpersandString = "#"
34 | return ""
35 | default:
36 | if let pending = pendingAmpersandString {
37 | guard character.isLetter || character.isNumber else {
38 | return flushPendingAmpersandString(withSuffix: String(character))
39 | }
40 |
41 | pendingAmpersandString = "\(pending)\(character)"
42 | return ""
43 | }
44 |
45 | return "\(character)"
46 | }
47 | }) + flushPendingAmpersandString()
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/Plot/API/ComponentBuilder.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Result builder used to combine all of the `Component` expressions that appear
10 | /// within a given attributed scope into a single `ComponentGroup`.
11 | ///
12 | /// You can annotate any function or closure with the `@ComponentBuilder` attribute
13 | /// to have its contents be processed by this builder. Note that you never have to
14 | /// call any of the methods defined within this type directly. Instead, the Swift
15 | /// compiler will automatically map your expressions to calls into this builder type.
16 | @resultBuilder public enum ComponentBuilder {
17 | /// Build a `ComponentGroup` from a list of components.
18 | /// - parameter components: The components that should be included in the group.
19 | public static func buildBlock(_ components: Component...) -> ComponentGroup {
20 | ComponentGroup(members: components)
21 | }
22 |
23 | /// Build a flattened `ComponentGroup` from an array of component groups.
24 | /// - parameter groups: The component groups to flatten into a single group.
25 | public static func buildArray(_ groups: [ComponentGroup]) -> ComponentGroup {
26 | ComponentGroup(members: groups.flatMap { $0 })
27 | }
28 |
29 | /// Pick the first `ComponentGroup` within a conditional statement.
30 | /// - parameter component: The component to pick.
31 | public static func buildEither(first component: ComponentGroup) -> ComponentGroup {
32 | component
33 | }
34 |
35 | /// Pick the second `ComponentGroup` within a conditional statement.
36 | /// - parameter component: The component to pick.
37 | public static func buildEither(second component: ComponentGroup) -> ComponentGroup {
38 | component
39 | }
40 |
41 | /// Build a `ComponentGroup` from an optional group.
42 | /// - parameter component: The optional to transform into a concrete group.
43 | public static func buildOptional(_ component: ComponentGroup?) -> ComponentGroup {
44 | component ?? ComponentGroup(members: [])
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Element.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of an element within a document, such as an HTML or XML tag.
10 | /// You normally don't construct `Element` values manually, but rather use Plot's
11 | /// various DSL APIs to create them, for example by creating a `` tag using
12 | /// `.body()`, or a `` tag using `.p()`.
13 | public struct Element: AnyElement {
14 | /// The name of the element
15 | public var name: String
16 | /// How the element is closed, for example if it's self-closing or if it can
17 | /// contain child elements.
18 | public var closingMode: ClosingMode = .standard
19 |
20 | internal var nodes: [AnyNode]
21 | internal var paddingCharacter: Character? = nil
22 | }
23 |
24 | public extension Element {
25 | /// Convenience shorthand for `ElementClosingMode`.
26 | typealias ClosingMode = ElementClosingMode
27 |
28 | /// Create a custom element with a given name and array of child nodes.
29 | /// - parameter name: The name of the element to create.
30 | /// - parameter nodes: The nodes (child elements + attributes) to add to the element.
31 | static func named(_ name: String, nodes: [Node]) -> Element {
32 | Element(name: name, nodes: nodes)
33 | }
34 |
35 | /// Create a custom self-closed element with a given name and array of attributes.
36 | /// - parameter name: The name of the element to create.
37 | /// - parameter attributes The attributes to add to the element.
38 | static func selfClosed(named name: String,
39 | attributes: [Attribute]) -> Element {
40 | Element(name: name, closingMode: .selfClosing, nodes: attributes.map(\.node))
41 | }
42 | }
43 |
44 | extension Element: NodeConvertible {
45 | public var node: Node { .element(self) }
46 | }
47 |
48 | extension Element: Component where Context == Any {
49 | public var body: Component { node }
50 |
51 | public init(
52 | name: String,
53 | @ComponentBuilder content: @escaping ContentProvider
54 | ) {
55 | self.init(name: name, nodes: [Node.component(content())])
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Document.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Protocol used to define a document format. Plot ships with a number
10 | /// of different implementations of this protocol, such as `HTML`, `RSS`,
11 | /// and `XML`, but you can also create your own types by conforming to it.
12 | ///
13 | /// Built-in document types are created simply by initializing them, while
14 | /// custom ones can be created using the `Document.custom` APIs.
15 | public protocol DocumentFormat {
16 | /// The root context of the document, which all top-level elements are
17 | /// bound to. Each document format is free to define any number of contexts
18 | /// in order to limit where an element or attribute may be placed.
19 | associatedtype RootContext
20 | }
21 |
22 | /// A representation of a document, which is the root element for all formats
23 | /// that can be expressed using Plot. For example, an HTML document will have
24 | /// the type `Document`, and an RSS feed `Document`.
25 | ///
26 | /// You normally don't have to interact with this type directly, unless you want
27 | /// to define your own custom document format, since the built-in formats (such
28 | /// as `HTML`, `RSS` and `XML`) completely wrap this type. To create custom
29 | /// `Document` values, use the `.custom` static factory methods.
30 | public struct Document {
31 | /// The root elements that make up this document. See `Element` for more info.
32 | public var elements: [Element]
33 |
34 | internal init(elements: [Element]) {
35 | self.elements = elements
36 | }
37 | }
38 |
39 | public extension Document {
40 | /// Create a `Document` value using a custom `DocumentFormat` type, with a list
41 | /// of elements that make up the root of the document.
42 | /// - parameter elements: The new document's root elements
43 | static func custom(_ elements: Element...) -> Self {
44 | Document(elements: elements)
45 | }
46 |
47 | /// Create a `Document` value using an explicitly passed custom `DocumentFormat`
48 | /// type, with a list of elements that make up the root of the document.
49 | /// - parameter format: The `DocumentFormat` type to bind the document to.
50 | /// - parameter elements: The new document's root elements
51 | static func custom(withFormat format: Format.Type,
52 | elements: [Element] = []) -> Self {
53 | Document(elements: elements)
54 | }
55 | }
56 |
57 | extension Document: NodeConvertible {
58 | public var node: Node { .document(self) }
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/Plot/Internal/ElementRenderingBuffer.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | internal final class ElementRenderingBuffer {
8 | var containsChildElements = false
9 |
10 | private let element: AnyElement
11 | private let indentation: Indentation?
12 | private var body = ""
13 | private var attributes = [AnyAttribute]()
14 | private var attributeIndexes = [String : Int]()
15 |
16 | init(element: AnyElement, indentation: Indentation?) {
17 | self.element = element
18 | self.indentation = indentation
19 | }
20 |
21 | func add(_ attribute: AnyAttribute) {
22 | if let existingIndex = attributeIndexes[attribute.name] {
23 | if attribute.replaceExisting {
24 | attributes[existingIndex].value = attribute.value
25 | } else if let newValue = attribute.nonEmptyValue {
26 | if let existingValue = attributes[existingIndex].nonEmptyValue {
27 | attributes[existingIndex].value = existingValue + " " + newValue
28 | } else {
29 | attributes[existingIndex].value = newValue
30 | }
31 | }
32 | } else {
33 | attributeIndexes[attribute.name] = attributes.count
34 | attributes.append(attribute)
35 | }
36 | }
37 |
38 | func add(_ text: String, isPlainText: Bool) {
39 | if !isPlainText, indentation != nil {
40 | body.append("\n")
41 | }
42 |
43 | body.append(text)
44 | }
45 |
46 | func flush() -> String {
47 | guard !element.name.isEmpty else { return body }
48 |
49 | let whitespace = indentation?.string ?? ""
50 | let padding = element.paddingCharacter.map(String.init) ?? ""
51 | var openingTag = "\(whitespace)<\(padding)\(element.name)"
52 |
53 | for attribute in attributes {
54 | let string = attribute.render()
55 |
56 | if !string.isEmpty {
57 | openingTag.append(" " + string)
58 | }
59 | }
60 |
61 | let openingTagSuffix = padding + ">"
62 |
63 | switch element.closingMode {
64 | case .standard,
65 | .selfClosing where containsChildElements:
66 | var string = openingTag + openingTagSuffix + body
67 |
68 | if indentation != nil && containsChildElements {
69 | string.append("\n\(whitespace)")
70 | }
71 |
72 | return string + "\(element.name)>"
73 | case .neverClosed:
74 | return openingTag + openingTagSuffix + body
75 | case .selfClosing:
76 | return openingTag + "/" + openingTagSuffix
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/Plot/API/SiteMapElements.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | // MARK: - Root
10 |
11 | public extension Element where Context == SiteMap.RootContext {
12 | /// Add a `` element within the current context.
13 | /// - parameter nodes: The element's attributes and child elements.
14 | static func urlset(_ nodes: Node...) -> Element {
15 | let attributes: [Attribute] = [
16 | Attribute(
17 | name: "xmlns",
18 | value: "http://www.sitemaps.org/schemas/sitemap/0.9"
19 | ),
20 | Attribute(
21 | name: "xmlns:image",
22 | value: "http://www.google.com/schemas/sitemap-image/1.1"
23 | )
24 | ]
25 |
26 | return Element(
27 | name: "urlset",
28 | nodes: attributes.map(\.node) + nodes
29 | )
30 | }
31 | }
32 |
33 | // MARK: - URLs
34 |
35 | public extension Node where Context == SiteMap.URLSetContext {
36 | /// Add a `` element within the current context.
37 | /// - parameter nodes: The element's child elements.
38 | static func url(_ nodes: Node...) -> Node {
39 | .element(named: "url", nodes: nodes)
40 | }
41 | }
42 |
43 | public extension Node where Context == SiteMap.URLContext {
44 | /// Define the URL's location.
45 | /// - parameter url: The canonical location URL.
46 | static func loc(_ url: URLRepresentable) -> Node {
47 | .element(named: "loc", text: url.string)
48 | }
49 |
50 | /// Define the frequency at which the URL's content is expected to change.
51 | /// - parameter frequency: The frequency to define (see `SiteMapChangeFrequency`).
52 | static func changefreq(_ frequency: SiteMapChangeFrequency) -> Node {
53 | .element(named: "changefreq", text: frequency.rawValue)
54 | }
55 |
56 | /// Define the priority of indexing this URL.
57 | /// - parameter priority: A priority value between 0 and 1.
58 | static func priority(_ priority: Double) -> Node {
59 | .element(named: "priority", text: String(priority))
60 | }
61 |
62 | /// Declare when the URL's content was last modified.
63 | /// - parameter date: The date the URL's content was last modified.
64 | /// - parameter timeZone: The time zone of the given `Date` (default: `.current`).
65 | static func lastmod(_ date: Date, timeZone: TimeZone = .current) -> Node {
66 | let formatter = SiteMap.dateFormatter
67 | formatter.timeZone = timeZone
68 | let dateString = formatter.string(from: date)
69 | return .element(named: "lastmod", text: dateString)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Tests/PlotTests/DocumentTests.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import XCTest
8 | import Plot
9 |
10 | final class DocumentTests: XCTestCase {
11 | func testEmptyDocument() {
12 | let document = Document.custom()
13 | XCTAssertEqual(document.render(), "")
14 | }
15 |
16 | func testEmptyIndentedDocument() {
17 | let document = Document.custom()
18 | XCTAssertEqual(document.render(indentedBy: .spaces(4)), "")
19 | }
20 |
21 | func testIndentationWithSpaces() {
22 | let document = Document.custom(
23 | withFormat: FormatStub.self,
24 | elements: [
25 | .named("one", nodes: [
26 | .element(named: "two", nodes: [
27 | .selfClosedElement(named: "three")
28 | ]),
29 | .text("four "),
30 | .component(Text("five")),
31 | .component(Element.named("six", nodes: [
32 | .text("seven")
33 | ])),
34 | .element(named: "eight", nodes: [
35 | .text("nine")
36 | ])
37 | ]),
38 | .selfClosed(named: "ten", attributes: [
39 | Attribute(name: "key", value: "value")
40 | ])
41 | ]
42 | )
43 |
44 | XCTAssertEqual(document.render(indentedBy: .spaces(4)), """
45 |
46 |
47 |
48 | four five
49 | seven
50 | nine
51 |
52 |
53 | """)
54 | }
55 |
56 | func testIndentationWithTabs() {
57 | let document = Document.custom(
58 | withFormat: FormatStub.self,
59 | elements: [
60 | .named("one", nodes: [
61 | .element(named: "two", nodes: [
62 | .selfClosedElement(named: "three")
63 | ]),
64 | .element(named: "four")
65 | ]),
66 | .selfClosed(named: "five", attributes: [
67 | Attribute(name: "key", value: "value")
68 | ])
69 | ]
70 | )
71 |
72 | XCTAssertEqual(document.render(indentedBy: .tabs(1)), """
73 |
74 | \t
75 | \t\t
76 | \t
77 | \t
78 |
79 |
80 | """)
81 | }
82 | }
83 |
84 | private extension DocumentTests {
85 | struct FormatStub: DocumentFormat {
86 | enum RootContext {}
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Plot/API/ControlFlow.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | public extension Node {
8 | /// Conditionally create a given node if a boolean expression is `true`,
9 | /// optionally falling back to another node using the `else` argument.
10 | /// - parameter condition: The boolean condition to evaluate.
11 | /// - parameter node: The node to add if the condition is `true`.
12 | /// - parameter fallbackNode: An optional node to fall back to in case
13 | /// the condition is `false`.
14 | static func `if`(_ condition: Bool,
15 | _ node: Node,
16 | else fallbackNode: Node? = nil) -> Node {
17 | guard condition else {
18 | return fallbackNode ?? .empty
19 | }
20 |
21 | return node
22 | }
23 |
24 | /// Conditionally create a given node by unwrapping an optional, and then
25 | /// applying a transform to it. If the optional is `nil`, then no node will
26 | /// be created.
27 | /// - parameter optional: The optional value to unwrap.
28 | /// - parameter transform: The closure to use to transform the value into a node.
29 | /// - parameter fallbackNode: An optional node to fall back to in case
30 | /// the passed `optional` is `nil`.
31 | static func unwrap(_ optional: T?,
32 | _ transform: (T) throws -> Node,
33 | else fallbackNode: Node = .empty) rethrows -> Node {
34 | try optional.map(transform) ?? fallbackNode
35 | }
36 |
37 | /// Transform any sequence of values into a group of nodes, by applying a
38 | /// transform to each element.
39 | /// - parameter sequence: The sequence to transform.
40 | /// - parameter transform: The closure to use to transform each element into a node.
41 | static func forEach(_ sequence: S,
42 | _ transform: (S.Element) throws -> Node) rethrows -> Node {
43 | try .group(sequence.map(transform))
44 | }
45 | }
46 |
47 | public extension Attribute {
48 | /// Conditionally create a given attribute by unwrapping an optional, and then
49 | /// applying a transform to it. If the optional is `nil`, then no attribute will
50 | /// be created.
51 | /// - parameter optional: The optional value to unwrap.
52 | /// - parameter transform: The closure to use to transform the value into an attribute.
53 | /// - parameter fallbackAttribute: An optional attribute to fall back to in case
54 | /// the passed `optional` is `nil`.
55 | static func unwrap(_ optional: T?,
56 | _ transform: (T) throws -> Self,
57 | else fallbackAttribute: Self = .empty) rethrows -> Self {
58 | try optional.map(transform) ?? fallbackAttribute
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Attribute.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of an element attribute, for example an HTML element's
10 | /// `id` or `class`. You normally don't construct `Attribute` values manually,
11 | /// but rather use Plot's various DSL APIs to create them, for example by using
12 | /// the `id()` or `class()` modifier on an HTML element.
13 | public struct Attribute {
14 | /// The name of the attribute
15 | public var name: String
16 | /// The attribute's value
17 | public var value: String?
18 | /// Whether the attribute's value should replace any existing one that has
19 | /// already been added to a given element for the same attribute name.
20 | public var replaceExisting: Bool
21 | /// Whether the attribute should be completely ignored if it has no value.
22 | public var ignoreIfValueIsEmpty: Bool
23 |
24 | /// Create a new `Attribute` instance with a name and a value, and optionally
25 | /// opt out of ignoring the attribute if its value is empty, and decide whether the
26 | /// attribute should replace any existing one that's already been added to an element
27 | /// for the same name.
28 | public init(name: String,
29 | value: String?,
30 | replaceExisting: Bool = true,
31 | ignoreIfValueIsEmpty: Bool = true) {
32 | self.name = name
33 | self.value = value
34 | self.replaceExisting = replaceExisting
35 | self.ignoreIfValueIsEmpty = ignoreIfValueIsEmpty
36 | }
37 | }
38 |
39 | public extension Attribute {
40 | /// Create a completely empty attribute, that's ignored during rendering.
41 | /// This is useful in contexts where you need to return an `Attribute`, but
42 | /// your logic determines that nothing should be added.
43 | static var empty: Attribute {
44 | Attribute(name: "", value: nil)
45 | }
46 |
47 | /// Create an attribute with a given name and value. This is the recommended
48 | /// way of creating completely custom attributes, or ones that Plot does not
49 | /// yet support, when within an attribute context.
50 | static func attribute(named name: String, value: String?) -> Self {
51 | Attribute(name: name, value: value)
52 | }
53 | }
54 |
55 | internal extension Attribute where Context == Any {
56 | static func any(name: String, value: String) -> Attribute {
57 | Attribute(name: name, value: value)
58 | }
59 | }
60 |
61 | extension Attribute: NodeConvertible {
62 | public var node: Node { .attribute(self) }
63 | }
64 |
65 | extension Attribute: AnyAttribute {
66 | func render() -> String {
67 | guard let value = nonEmptyValue else {
68 | return ignoreIfValueIsEmpty ? "" : name
69 | }
70 |
71 | return "\(name)=\"\(value)\""
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/Plot/API/PodcastFeed.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of a podcast feed. Create an instance of this
10 | /// type to build a feed using Plot's type-safe DSL, and then call
11 | /// the `render()` method to turn it into an RSS string.
12 | public struct PodcastFeed: RSSBasedDocumentFormat {
13 | private let document: Document
14 |
15 | /// Create a podcast feed with a collection of nodes that make
16 | /// up the items (episodes) in the feed. Each item can be created
17 | /// using the `.item()` API.
18 | /// - parameter nodes: The nodes that make up the podcast's
19 | /// episodes. Will be placed within a `` element.
20 | public init(_ nodes: Node...) {
21 | document = .feed(
22 | .namespace("itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"),
23 | .namespace("media", "http://www.rssboard.org/media-rss"),
24 | .channel(.group(nodes))
25 | )
26 | }
27 | }
28 |
29 | extension PodcastFeed: NodeConvertible {
30 | public var node: Node { document.node }
31 | }
32 |
33 | public extension PodcastFeed {
34 | /// The root context of a podcast feed. Plot automatically creates
35 | /// all required elements within this context for you.
36 | enum RootContext: RSSRootContext {}
37 | /// The context within the top level of a podcast feed, within the
38 | /// `` element. Plot automatically creates all required elements
39 | /// within this context for you.
40 | enum FeedContext: RSSFeedContext {
41 | public typealias ChannelContext = PodcastFeed.ChannelContext
42 | }
43 | /// The context within a podcast's `` element, in which
44 | /// episodes can be defined.
45 | enum ChannelContext: RSSChannelContext, PodcastContentContext, PodcastCategoryContext {
46 | public typealias ItemContext = PodcastFeed.ItemContext
47 | }
48 | /// The context within a podcast's `` element.
49 | enum CategoryContext: PodcastCategoryContext {}
50 | /// The context within a podcast episode's `` element.
51 | enum EnclosureContext {}
52 | /// The context within a podcast episode's `- ` element.
53 | enum ItemContext: PodcastContentContext, RSSItemContext {}
54 | /// The context within a podcast episode's `` element.
55 | enum MediaContext {}
56 | /// The context within a podcast's `` element.
57 | enum OwnerContext: PodcastNameableContext {}
58 | }
59 |
60 | /// Context shared among all elements that define podcast categories.
61 | public protocol PodcastCategoryContext: PodcastNameableContext {}
62 | /// Context shared among all elements that define podcast content.
63 | public protocol PodcastContentContext: RSSContentContext {}
64 | /// Context shared among all elements that define podcast names.
65 | public protocol PodcastNameableContext {}
66 |
--------------------------------------------------------------------------------
/Tests/PlotTests/NodeTests.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import XCTest
8 | import Plot
9 |
10 | final class NodeTests: XCTestCase {
11 | func testEscapingText() {
12 | let node = Node.text("Hello & welcome to !;")
13 | XCTAssertEqual(node.render(), "Hello & welcome to <Plot>!;")
14 | }
15 |
16 | func testEscapingDoubleAmpersands() {
17 | let node = Node.text("&&")
18 | XCTAssertEqual(node.render(), "&&")
19 | }
20 |
21 | func testEscapingAmpersandFollowedByComparisonSymbols() {
22 | let node = Node.text("&< &>")
23 | XCTAssertEqual(node.render(), "&< &>")
24 | }
25 |
26 | func testNotDoubleEscapingText() {
27 | let node = Node.text("Hello & welcome to <Plot>!&text")
28 | XCTAssertEqual(node.render(), "Hello & welcome to <Plot>!&text")
29 | }
30 |
31 | func testNotEscapingRawString() {
32 | let node = Node.raw("Hello & welcome to !")
33 | XCTAssertEqual(node.render(), "Hello & welcome to !")
34 | }
35 |
36 | func testGroup() {
37 | let node = Node.group(.text("Hello"), .text("World"))
38 | XCTAssertEqual(node.render(), "HelloWorld")
39 | }
40 |
41 | func testCustomElement() {
42 | let node = Node.element(named: "custom")
43 | XCTAssertEqual(node.render(), "")
44 | }
45 |
46 | func testCustomAttribute() {
47 | let node = Node.attribute(named: "key", value: "value")
48 | XCTAssertEqual(node.render(), #"key="value""#)
49 | }
50 |
51 | func testCustomElementWithCustomAttribute() {
52 | let node = Node.element(named: "custom", attributes: [
53 | Attribute(name: "key", value: "value")
54 | ])
55 |
56 | XCTAssertEqual(node.render(), #""#)
57 | }
58 |
59 | func testCustomElementWithCustomAttributeWithSpecificContext() {
60 | let node = Node.element(named: "custom", attributes: [
61 | Attribute(name: "key", value: "value")
62 | ])
63 |
64 | XCTAssertEqual(node.render(), #""#)
65 | }
66 |
67 | func testCustomSelfClosedElementWithCustomAttribute() {
68 | let node = Node.selfClosedElement(named: "custom", attributes: [
69 | Attribute(name: "key", value: "value")
70 | ])
71 |
72 | XCTAssertEqual(node.render(), #""#)
73 | }
74 |
75 | func testComponents() {
76 | let node = Node.components {
77 | Paragraph("One")
78 | Paragraph("Two")
79 | }
80 |
81 | XCTAssertEqual(node.render(), "
One
Two
")
82 | }
83 |
84 | func testNodeComponentBodyIsEqualToSelf() {
85 | let node = Node.p("Text")
86 | XCTAssertEqual(node.render(), node.body.render())
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Indentation.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of a kind of indentation at a given level.
10 | public struct Indentation: Codable, Equatable {
11 | /// The kind of the indentation (see `Kind`).
12 | public var kind: Kind
13 | /// The level of the indentation (0 = root).
14 | public var level = 0
15 |
16 | /// Initialize an instance for a given kind of indentation.
17 | public init(kind: Kind) {
18 | self.kind = kind
19 | }
20 | }
21 |
22 | public extension Indentation {
23 | /// Enum defining various kinds of indentation that a document
24 | /// can be rendered using.
25 | enum Kind: Equatable {
26 | /// Each level should be indented by a given number of tabs.
27 | case tabs(Int)
28 | /// Each level should be indented by a given number of spaces.
29 | case spaces(Int)
30 | }
31 | }
32 |
33 | internal extension Indentation {
34 | func indented() -> Indentation {
35 | var indentation = self
36 | indentation.level += 1
37 | return indentation
38 | }
39 | }
40 |
41 | extension Indentation: CustomStringConvertible {
42 | public var string: String { description }
43 |
44 | public var description: String {
45 | String(repeating: kind.description, count: level)
46 | }
47 | }
48 |
49 | extension Indentation.Kind: CustomStringConvertible {
50 | public var description: String {
51 | switch self {
52 | case .tabs(let count):
53 | return String(repeating: "\t", count: count)
54 | case .spaces(let count):
55 | return String(repeating: " ", count: count)
56 | }
57 | }
58 | }
59 |
60 | extension Indentation.Kind: Codable {
61 | private enum CodingKeys: CodingKey {
62 | case kind
63 | case count
64 | }
65 |
66 | public init(from decoder: Decoder) throws {
67 | let container = try decoder.container(keyedBy: CodingKeys.self)
68 | let kind = try container.decode(String.self, forKey: .kind)
69 | let count = try container.decode(Int.self, forKey: .count)
70 |
71 | switch kind {
72 | case "tabs":
73 | self = .tabs(count)
74 | case "spaces":
75 | self = .spaces(count)
76 | default:
77 | throw DecodingError.dataCorruptedError(
78 | forKey: CodingKeys.kind,
79 | in: container,
80 | debugDescription: "'\(kind)' is not an indentation kind"
81 | )
82 | }
83 | }
84 |
85 | public func encode(to encoder: Encoder) throws {
86 | var container = encoder.container(keyedBy: CodingKeys.self)
87 |
88 | switch self {
89 | case .tabs(let count):
90 | try container.encode("tabs", forKey: .kind)
91 | try container.encode(count, forKey: .count)
92 | case .spaces(let count):
93 | try container.encode("spaces", forKey: .kind)
94 | try container.encode(count, forKey: .count)
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Sources/Plot/API/HTMLAnchorRelationship.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// An enum that defines various values for an HTML anchor's `rel`
10 | /// attribute, which specifies the relationship that the anchor has
11 | /// to the URL that it's linking to.
12 | public struct HTMLAnchorRelationship: RawRepresentable, Identifiable, ExpressibleByStringLiteral {
13 | public var id: String { rawValue }
14 | public var rawValue: String
15 |
16 | public init(rawValue: String) {
17 | self.rawValue = rawValue
18 | }
19 |
20 | public init(stringLiteral value: StringLiteralType) {
21 | self.rawValue = value
22 | }
23 |
24 | // MARK: Default Values
25 |
26 | /// Provides a link to an alternate representation of the document (i.e. print page, translated or mirror)
27 | public static let alternate: HTMLAnchorRelationship = "alternate"
28 |
29 | /// Provides a link to the author of the document
30 | public static let author: HTMLAnchorRelationship = "author"
31 |
32 | /// Permanent URL used for bookmarking
33 | public static let bookmark: HTMLAnchorRelationship = "bookmark"
34 |
35 | /// Indicates that the referenced document is not part of the same site as the current document
36 | public static let external: HTMLAnchorRelationship = "external"
37 |
38 | /// Provides a link to a help document
39 | public static let help: HTMLAnchorRelationship = "help"
40 |
41 | /// Provides a link to licensing information for the document
42 | public static let license: HTMLAnchorRelationship = "license"
43 |
44 | /// Provides a link to the next document in the series
45 | public static let next: HTMLAnchorRelationship = "next"
46 |
47 | /// Links to an unendorsed document, like a paid link.
48 | /// - Note: "nofollow" is used by Google, to specify that the Google search spider should not follow that link
49 | public static let nofollow: HTMLAnchorRelationship = "nofollow"
50 |
51 | /// Requires that any browsing context created by following the hyperlink must not have an opener browsing context
52 | public static let noopener: HTMLAnchorRelationship = "noopener"
53 |
54 | /// Makes the referrer unknown. No referer header will be included when the user clicks the hyperlink
55 | public static let noreferrer: HTMLAnchorRelationship = "noreferrer"
56 |
57 | /// The previous document in a selection
58 | public static let prev: HTMLAnchorRelationship = "prev"
59 |
60 | /// Links to a search tool for the document
61 | public static let search: HTMLAnchorRelationship = "search"
62 |
63 | /// A tag (keyword) for the current document
64 | public static let tag: HTMLAnchorRelationship = "tag"
65 |
66 | /// For displaying augmented reality content in iOS Safari.
67 | /// Adding this tag will instruct Safari to directly open the content
68 | /// rather than navigating to a new page.
69 | /// https://webkit.org/blog/8421/viewing-augmented-reality-assets-in-safari-for-ios/
70 | public static let ar: HTMLAnchorRelationship = "ar"
71 |
72 | /// The opposite of `noopener`.
73 | public static let opener: HTMLAnchorRelationship = "opener"
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/Plot/API/HTMLListStyle.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Type that represents a way to style `List` components.
10 | ///
11 | /// Plot ships with two ready-made list styles, `ordered` (which
12 | /// maps to the `` element), and `unordered` (which maps to
13 | /// the `` element).
14 | ///
15 | /// You can apply a list style to an entire component hierarchy
16 | /// using the `listStyle` modifier, and you can access the current
17 | /// style within a component using the `listStyle` environment key.
18 | public struct HTMLListStyle {
19 | /// Closure type that's used to wrap an item within a `List` into
20 | /// a renderable component.
21 | public typealias ItemWrapper = (Component) -> Component
22 |
23 | /// The name of the element that should be used to render a list
24 | /// styled with this style.
25 | public var elementName: String
26 | /// A closure that's used to wrap each list item into a renderable
27 | /// component.
28 | public var itemWrapper: (Component) -> Component
29 |
30 | /// Create a new, custom list style.
31 | /// - parameter elementName: The name of the element that should be
32 | /// used to render a list styled with this style.
33 | /// - parameter itemWrapper: A closure that wraps each item within a
34 | /// styled list into a renderable component. Defaults to wrapping
35 | /// each item into an `- ` element, if needed.
36 | public init(
37 | elementName: String,
38 | itemWrapper: @escaping ItemWrapper = defaultItemWrapper
39 | ) {
40 | self.elementName = elementName
41 | self.itemWrapper = itemWrapper
42 | }
43 | }
44 |
45 | public extension HTMLListStyle {
46 | /// The default `ItemWrapper` closure that's used for all built-in `ListStyle`
47 | /// variants, and also acts as the default when creating custom ones. Wraps each
48 | /// item into an `
- ` element, if needed.
49 | static let defaultItemWrapper: ItemWrapper = { $0.wrappedInElement(named: "li") }
50 | /// List style that renders each `List` as unordered, using the `
` element.
51 | static var unordered: Self { HTMLListStyle(elementName: "ul") }
52 | /// List style that renders each `List` as ordered, using the `` element.
53 | static var ordered: Self { HTMLListStyle(elementName: "ol") }
54 |
55 | /// Apply a certain class name to each item within lists styled by this style.
56 | /// - parameter className: The class name to apply. Will be applied to each
57 | /// item after it's been wrapped using the `itemWrapper` closure.
58 | func withItemClass(_ className: String) -> Self {
59 | modifyingItems { $0.class(className) }
60 | }
61 |
62 | /// Use a closure to modify each item within lists styled by this style.
63 | /// - parameter modifier: The modifier closure to apply. Will recieve each
64 | /// wrapped item (after it's been passed to the style's `itemWrapper`),
65 | /// and is expected to return a new, transformed component.
66 | func modifyingItems(with modifier: @escaping (Component) -> Component) -> Self {
67 | var style = self
68 | style.itemWrapper = { modifier(itemWrapper($0)) }
69 | return style
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/Plot/API/RSS.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Protocol adopted by all document formats that are based on RSS.
10 | public protocol RSSBasedDocumentFormat: DocumentFormat where RootContext: RSSRootContext {
11 | /// The context of the document's feed
12 | associatedtype FeedContext: RSSFeedContext
13 | }
14 |
15 | /// A representation of an RSS feed. Create an instance of this type
16 | /// to build an RSS feed using Plot's type-safe DSL, and then call the
17 | /// `render()` method to turn it into an RSS string.
18 | public struct RSS: RSSBasedDocumentFormat {
19 | private let document: Document
20 |
21 | /// Create an RSS feed with a collection of nodes that make up the
22 | /// items in the feed. Each item can be created using the `.item()`
23 | /// API.
24 | /// - parameter nodes: The nodes that make up the feed's items.
25 | /// Will be placed within a `` element.
26 | public init(_ nodes: Node...) {
27 | document = .feed(.channel(.group(nodes)))
28 | }
29 | }
30 |
31 | extension RSS: NodeConvertible {
32 | public var node: Node { document.node }
33 | }
34 |
35 | public extension RSS {
36 | /// The root context of an RSS feed. Plot automatically creates
37 | /// all required elements within this context for you.
38 | enum RootContext: RSSRootContext {}
39 | /// The context within the top level of a podcast feed, within the
40 | /// `` element. Plot automatically creates all required elements
41 | /// within this context for you.
42 | enum FeedContext: RSSFeedContext {
43 | public typealias ChannelContext = RSS.ChannelContext
44 | }
45 | /// The context within a feed's `` element, in which
46 | /// items can be defined.
47 | enum ChannelContext: RSSChannelContext, RSSContentContext {
48 | public typealias ItemContext = RSS.ItemContext
49 | }
50 | /// The context within an RSS feed's `- ` elements.
51 | enum ItemContext: RSSItemContext {}
52 | /// The context within an RSS item's `` element.
53 | enum GUIDContext {}
54 | }
55 |
56 | /// Protocol adopted by all contexts that are at the root level of
57 | /// an RSS-based document format.
58 | public protocol RSSRootContext: XMLRootContext {}
59 | /// Protocol adopted by all contexts that define an RSS feed.
60 | public protocol RSSFeedContext {
61 | /// The feed's channel context.
62 | associatedtype ChannelContext: RSSChannelContext
63 | }
64 | /// Protocol adopted by all contexts that define an RSS channel.
65 | public protocol RSSChannelContext {
66 | /// The channel's item context.
67 | associatedtype ItemContext: RSSItemContext
68 | }
69 | /// Protocol adopted by all contexts that define RSS-based content.
70 | public protocol RSSContentContext {}
71 | /// Protocol adopted by all contexts that define an RSS item.
72 | public protocol RSSItemContext: RSSContentContext {}
73 |
74 | internal extension RSS {
75 | static let dateFormatter: DateFormatter = {
76 | let formatter = DateFormatter()
77 | formatter.dateFormat = "E, d MMM yyyy HH:mm:ss Z"
78 | formatter.locale = Locale(identifier: "en_US_POSIX")
79 | return formatter
80 | }()
81 | }
82 |
83 | internal extension Document where Format: RSSBasedDocumentFormat {
84 | static func feed(_ nodes: Node...) -> Document {
85 | Document(elements: [
86 | .xml(.version(1.0), .encoding(.utf8)),
87 | .rss(
88 | .version(2.0),
89 | .namespace("atom", "http://www.w3.org/2005/Atom"),
90 | .namespace("content", "http://purl.org/rss/1.0/modules/content/"),
91 | .group(nodes)
92 | )
93 | ])
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Sources/Plot/API/EnvironmentKey.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Type used to define an environment key, which can be used to pass a given
10 | /// value downward through a component/node hierarchy until its overridden by
11 | /// another value for the same key. You can place values into the environment
12 | /// using the `environmentValue` modifier, and you can then retrieve those
13 | /// values within any component using the `EnvironmentValue` property wrapper.
14 | public struct EnvironmentKey {
15 | internal let identifier: StaticString
16 | internal let defaultValue: Value
17 |
18 | /// Initialize a key with an explicit identifier and a default value.
19 | /// - parameter identifier: The identifier that the key should have. Must
20 | /// be a static string that's defined using a compile time literal.
21 | /// - parameter defaultValue: The default value that should be provided
22 | /// to components when no parent component assigned a value for this key.
23 | public init(identifier: StaticString, defaultValue: Value) {
24 | self.identifier = identifier
25 | self.defaultValue = defaultValue
26 | }
27 | }
28 |
29 | public extension EnvironmentKey {
30 | /// Initialize a key with an inferred identifier and a default value. The
31 | /// key's identifier will be computed based on the name of the property or
32 | /// function that created it.
33 | /// - parameter defaultValue: The default value that should be provided
34 | /// to components when no parent component assigned a value for this key.
35 | /// - parameter autoIdentifier: This parameter will be filled in by the
36 | /// compiler based on the name of the call site's enclosing function/property.
37 | init(defaultValue: Value, autoIdentifier: StaticString = #function) {
38 | self.init(identifier: autoIdentifier, defaultValue: defaultValue)
39 | }
40 | }
41 |
42 | public extension EnvironmentKey {
43 | /// Initialize a key with an explicit identifier.
44 | /// - parameter identifier: The identifier that the key should have. Must
45 | /// be a static string that's defined using a compile time literal.
46 | init(identifier: StaticString) where Value == T? {
47 | self.init(identifier: identifier, defaultValue: nil)
48 | }
49 |
50 | /// Initialize a key with an inferred identifier. The key's identifier will
51 | /// be computed based on the name of the property or function that created it.
52 | /// - parameter autoIdentifier: This parameter will be filled in by the
53 | /// compiler based on the name of the call site's enclosing function/property.
54 | init(autoIdentifier: StaticString = #function) where Value == T? {
55 | self.init(identifier: autoIdentifier, defaultValue: nil)
56 | }
57 | }
58 |
59 | public extension EnvironmentKey where Value == HTMLAnchorRelationship? {
60 | /// Key used to define a relationship for `Link` components. The default is `nil`
61 | /// (that is, no explicitly defined relationship). See the `linkRelationship`
62 | /// modifier for more information.
63 | static var linkRelationship: Self { .init() }
64 | }
65 |
66 | public extension EnvironmentKey where Value == HTMLAnchorTarget? {
67 | /// Key used to define a target for `Link` components. The default is `nil`
68 | /// (that is, no explicitly defined target). See the `linkTarget` modifier
69 | /// for more information.
70 | static var linkTarget: Self { .init() }
71 | }
72 |
73 | public extension EnvironmentKey where Value == HTMLListStyle {
74 | /// Key used to define a style for `List` components. The default value uses
75 | /// the `unordered` style (which produces `
` elements). See the `listStyle`
76 | /// modifier for more information.
77 | static var listStyle: Self { .init(defaultValue: .unordered) }
78 | }
79 |
80 | public extension EnvironmentKey where Value == Bool? {
81 | /// Key used to define whether autocomplete should be enabled for `Input`
82 | /// components. The default is `nil` (that is, no explicitly defined value).
83 | /// See the `autoComplete` modifier for more information.
84 | static var isAutoCompleteEnabled: Self { .init() }
85 | }
86 |
--------------------------------------------------------------------------------
/Tests/PlotTests/RSSTests.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import XCTest
8 | import Plot
9 |
10 | final class RSSTests: XCTestCase {
11 | func testEmptyFeed() {
12 | let feed = RSS()
13 | assertEqualRSSFeedContent(feed, "")
14 | }
15 |
16 | func testFeedTitle() {
17 | let feed = RSS(.title("MyPodcast"))
18 | assertEqualRSSFeedContent(feed, "MyPodcast")
19 | }
20 |
21 | func testFeedDescription() {
22 | let feed = RSS(.description("Description"))
23 | assertEqualRSSFeedContent(feed, "Description")
24 | }
25 |
26 | func testFeedDescriptionWithHTMLContent() {
27 | let feed = RSS(
28 | .description(
29 | .p(
30 | .text("Description with "),
31 | .em("emphasis"),
32 | .text(".")
33 | )
34 | )
35 | )
36 | assertEqualRSSFeedContent(feed, "Description with emphasis.
]]>")
37 | }
38 |
39 | func testFeedURL() {
40 | let feed = RSS(.link("url.com"))
41 | assertEqualRSSFeedContent(feed, "url.com")
42 | }
43 |
44 | func testFeedAtomLink() {
45 | let feed = RSS(.atomLink("url.com"))
46 | assertEqualRSSFeedContent(feed, """
47 |
48 | """)
49 | }
50 |
51 | func testFeedLanguage() {
52 | let feed = RSS(.language(.usEnglish))
53 | assertEqualRSSFeedContent(feed, "en-us")
54 | }
55 |
56 | func testFeedTTL() {
57 | let feed = RSS(.ttl(200))
58 | assertEqualRSSFeedContent(feed, "200")
59 | }
60 |
61 | func testFeedPublicationDate() throws {
62 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
63 | let feed = RSS(.pubDate(stubs.date, timeZone: stubs.timeZone))
64 | assertEqualRSSFeedContent(feed, "\(stubs.expectedString)")
65 | }
66 |
67 | func testFeedLastBuildDate() throws {
68 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
69 | let feed = RSS(.lastBuildDate(stubs.date, timeZone: stubs.timeZone))
70 | assertEqualRSSFeedContent(feed, "\(stubs.expectedString)")
71 | }
72 |
73 | func testItemGUID() {
74 | let feed = RSS(
75 | .item(.guid("123")),
76 | .item(.guid("url.com", .isPermaLink(true))),
77 | .item(.guid("123", .isPermaLink(false)))
78 | )
79 |
80 | assertEqualRSSFeedContent(feed, """
81 | - 123
\
82 | - url.com
\
83 | - 123
84 | """)
85 | }
86 |
87 | func testItemTitle() {
88 | let feed = RSS(.item(.title("Title")))
89 | assertEqualRSSFeedContent(feed, "- Title
")
90 | }
91 |
92 | func testItemDescription() {
93 | let feed = RSS(.item(.description("Description")))
94 | assertEqualRSSFeedContent(feed, """
95 | - Description
96 | """)
97 | }
98 |
99 | func testItemURL() {
100 | let feed = RSS(.item(.link("url.com")))
101 | assertEqualRSSFeedContent(feed, "- url.com
")
102 | }
103 |
104 | func testItemPublicationDate() throws {
105 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
106 | let feed = RSS(.item(.pubDate(stubs.date, timeZone: stubs.timeZone)))
107 | assertEqualRSSFeedContent(feed, """
108 | - \(stubs.expectedString)
109 | """)
110 | }
111 |
112 | func testItemHTMLStringContent() {
113 | let feed = RSS(.item(.content(
114 | "Hello
World & Everyone!
"
115 | )))
116 |
117 | assertEqualRSSFeedContent(feed, """
118 | - \
119 | \
120 | Hello
World & Everyone!
]]>\
121 | \
122 |
123 | """)
124 | }
125 |
126 | func testItemHTMLDSLContent() {
127 | let feed = RSS(.item(
128 | .content(.h1("Title"))
129 | ))
130 |
131 | assertEqualRSSFeedContent(feed, """
132 | - \
133 | \
134 | Title]]>\
135 | \
136 |
137 | """)
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/Sources/Plot/API/ComponentAttributes.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | public extension Component {
10 | /// Assign an accessibility label to this component's element, which
11 | /// is used by assistive technologies to get a text representation of it.
12 | /// - parameter label: The label to assign.
13 | func accessibilityLabel(_ label: String) -> Component {
14 | attribute(named: "aria-label", value: label)
15 | }
16 |
17 | /// Assign a class name to this component's element. May also be a list
18 | /// of space-separated class names.
19 | /// - parameter className: The class or list of classes to assign.
20 | /// - parameter replaceExisting: Whether the new class name should replace
21 | /// any existing one. Defaults to `false`, which will instead cause the
22 | /// new class name to be appended to any existing one, separated by a space.
23 | func `class`(_ className: String, replaceExisting: Bool = false) -> Component {
24 | attribute(named: "class",
25 | value: className,
26 | replaceExisting: replaceExisting)
27 | }
28 |
29 | /// Add a `data-` attribute to this component's element.
30 | /// - parameter name: The name of the attribute to add. The name will be
31 | /// prefixed with `data-`.
32 | /// - parameter value: The attribute's string value.
33 | func data(named name: String, value: String) -> Component {
34 | attribute(named: "data-" + name, value: value)
35 | }
36 |
37 | /// Assign an ID attribute to this component's element.
38 | /// - parameter id: The ID to assign.
39 | func id(_ id: String) -> Component {
40 | attribute(named: "id", value: id)
41 | }
42 |
43 | /// Assign a directionality to this component's element.
44 | /// - parameter directionality: The directionality to assign.
45 | func directionality(_ directionality: Directionality) -> Component {
46 | attribute(named: "dir", value: directionality.rawValue)
47 | }
48 |
49 | /// Assign whether this component hierarchy's `Input` components should have
50 | /// autocomplete turned on or off. This value is placed in the environment, and
51 | /// is thus inherited by all child components. Note that this modifier only
52 | /// affects components, not elements created using the `Node.input` API, or
53 | /// manually created input elements.
54 | /// - parameter isEnabled: Whether autocomplete should be enabled.
55 | func autoComplete(_ isEnabled: Bool) -> Component {
56 | environmentValue(isEnabled, key: .isAutoCompleteEnabled)
57 | }
58 |
59 | /// Assign a given `HTMLAnchorRelationship` to all `Link` components within
60 | /// this component hierarchy. Affects the `rel` attribute on the generated
61 | /// `` elements. This value is placed in the environment, and is thus
62 | /// inherited by all child components. Note that this modifier only affects
63 | /// components, not elements created using the `Node.a` API, or manually
64 | /// created anchor elements.
65 | /// - parameter relationship: The relationship to assign.
66 | func linkRelationship(_ relationship: HTMLAnchorRelationship?) -> Component {
67 | environmentValue(relationship, key: .linkRelationship)
68 | }
69 |
70 | /// Assign a given `HTMLAnchorTarget` to all `Link` components within this
71 | /// component hierarchy. Affects the `target` attribute on the generated
72 | /// `` elements. This value is placed in the environment, and is thus
73 | /// inherited by all child components. Note that this modifier only affects
74 | /// components, not elements created using the `Node.a` API, or manually
75 | /// created anchor elements.
76 | /// - parameter target: The target to assign.
77 | func linkTarget(_ target: HTMLAnchorTarget?) -> Component {
78 | environmentValue(target, key: .linkTarget)
79 | }
80 |
81 | /// Assign a given `HTMLListStyle` to all `List` components within this
82 | /// component hierarchy. You can use this modifier to decide whether lists
83 | /// should be rendered as ordered or unordered, or even use a completely
84 | /// custom style. This value is placed in the environment, and is thus
85 | /// inherited by all child components. Note that this modifier only affects
86 | /// components, not elements created using the `Node.ul` or `Node.ol` APIs,
87 | /// or manually created list elements.
88 | /// - parameter style: The style to assign.
89 | func listStyle(_ style: HTMLListStyle) -> Component {
90 | environmentValue(style, key: .listStyle)
91 | }
92 |
93 | /// Assign a given set of inline CSS styles to this component's element.
94 | /// - parameter css: A string containing the CSS code that should be assigned
95 | /// to this component's `style` attribute.
96 | func style(_ css: String) -> Component {
97 | attribute(named: "style", value: css)
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/Sources/Plot/Internal/Renderer.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | internal struct Renderer {
8 | private(set) var result = ""
9 | private(set) var deferredAttributes = [AnyAttribute]()
10 |
11 | private let indentation: Indentation?
12 | private var environment: Environment
13 | private var elementWrapper: ElementWrapper?
14 | private var elementBuffer: ElementRenderingBuffer?
15 | private var containsElement = false
16 | }
17 |
18 | extension Renderer {
19 | static func render(
20 | _ node: AnyNode,
21 | indentedBy indentationKind: Indentation.Kind?
22 | ) -> String {
23 | var renderer = Renderer(indentationKind: indentationKind)
24 | node.render(into: &renderer)
25 | return renderer.result
26 | }
27 |
28 | init(indentationKind: Indentation.Kind?) {
29 | self.indentation = indentationKind.map(Indentation.init)
30 | self.environment = Environment()
31 | }
32 |
33 | mutating func renderRawText(_ text: String) {
34 | renderRawText(text, isPlainText: true, wrapIfNeeded: true)
35 | }
36 |
37 | mutating func renderText(_ text: String) {
38 | renderRawText(text.escaped())
39 | }
40 |
41 | mutating func renderElement(_ element: Element) {
42 | if let wrapper = elementWrapper {
43 | guard element.name == wrapper.wrappingElementName else {
44 | if deferredAttributes.isEmpty {
45 | return renderComponent(
46 | wrapper.body(Node.element(element)),
47 | deferredAttributes: wrapper.deferredAttributes
48 | )
49 | } else {
50 | return renderComponent(
51 | wrapper.body(ModifiedComponent(
52 | base: Node.element(element),
53 | deferredAttributes: deferredAttributes
54 | ))
55 | )
56 | }
57 | }
58 | }
59 |
60 | let buffer = ElementRenderingBuffer(
61 | element: element,
62 | indentation: indentation
63 | )
64 |
65 | var renderer = Renderer(
66 | indentation: indentation?.indented(),
67 | environment: environment,
68 | elementBuffer: buffer
69 | )
70 |
71 | element.nodes.forEach {
72 | $0.render(into: &renderer)
73 | }
74 |
75 | deferredAttributes.forEach(buffer.add)
76 | elementBuffer?.containsChildElements = true
77 | containsElement = true
78 |
79 | renderRawText(buffer.flush(),
80 | isPlainText: false,
81 | wrapIfNeeded: false
82 | )
83 | }
84 |
85 | mutating func renderAttribute(_ attribute: Attribute) {
86 | if let elementBuffer = elementBuffer {
87 | elementBuffer.add(attribute)
88 | } else {
89 | result.append(attribute.render())
90 | }
91 | }
92 |
93 | mutating func renderComponent(
94 | _ component: Component,
95 | deferredAttributes: [AnyAttribute] = [],
96 | environmentOverrides: [Environment.Override] = [],
97 | elementWrapper: ElementWrapper? = nil
98 | ) {
99 | var environment = self.environment
100 | environmentOverrides.forEach { $0.apply(to: &environment) }
101 |
102 | if !(component is AnyNode || component is AnyElement) {
103 | let componentMirror = Mirror(reflecting: component)
104 |
105 | for property in componentMirror.children {
106 | if let environmentValue = property.value as? AnyEnvironmentValue {
107 | environmentValue.environment.value = environment
108 | }
109 | }
110 | }
111 |
112 | var renderer = Renderer(
113 | deferredAttributes: deferredAttributes,
114 | indentation: indentation,
115 | environment: environment,
116 | elementWrapper: elementWrapper
117 | )
118 |
119 | if let node = component as? AnyNode {
120 | node.render(into: &renderer)
121 | } else {
122 | renderer.renderComponent(component.body,
123 | deferredAttributes: deferredAttributes,
124 | elementWrapper: elementWrapper ?? self.elementWrapper
125 | )
126 | }
127 |
128 | renderRawText(renderer.result,
129 | isPlainText: !renderer.containsElement,
130 | wrapIfNeeded: false
131 | )
132 |
133 | containsElement = renderer.containsElement
134 | }
135 | }
136 |
137 | private extension Renderer {
138 | mutating func renderRawText(
139 | _ text: String,
140 | isPlainText: Bool,
141 | wrapIfNeeded: Bool
142 | ) {
143 | if wrapIfNeeded {
144 | if let wrapper = elementWrapper {
145 | return renderComponent(wrapper.body(Node.raw(text)))
146 | }
147 | }
148 |
149 | if let elementBuffer = elementBuffer {
150 | elementBuffer.add(text, isPlainText: isPlainText)
151 | } else {
152 | if indentation != nil && !result.isEmpty {
153 | result.append("\n")
154 | }
155 |
156 | result.append(text)
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/Sources/Plot/API/RSSElements.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | // MARK: - Top-level
10 |
11 | public extension Element where Context: RSSRootContext {
12 | /// Add an `` element within the current context.
13 | /// - parameter nodes: The element's attributes and child elements.
14 | static func rss(_ nodes: Node...) -> Element {
15 | Element(name: "rss", closingMode: .standard, nodes: nodes)
16 | }
17 | }
18 |
19 | public extension Node where Context: RSSFeedContext {
20 | /// Add a `` element within the current context.
21 | /// - parameter nodes: The element's attributes and child elements.
22 | static func channel(_ nodes: Node...) -> Node {
23 | .element(named: "channel", nodes: nodes)
24 | }
25 | }
26 |
27 | // MARK: - Channel
28 |
29 | public extension Node where Context: RSSChannelContext {
30 | /// Define the channel's title
31 | /// - parameter title: The title of the channel.
32 | static func title(_ title: String) -> Node {
33 | .element(named: "title", text: title)
34 | }
35 |
36 | /// Define the channel's primary language.
37 | /// - parameter language: The channel's primary language.
38 | static func language(_ language: Language) -> Node {
39 | .element(named: "language", text: language.rawValue)
40 | }
41 |
42 | /// Declare when the feed was last built/generated.
43 | /// - parameter date: The date the feed was generated.
44 | /// - parameter timeZone: The time zone of the given `Date` (default: `.current`).
45 | static func lastBuildDate(_ date: Date,
46 | timeZone: TimeZone = .current) -> Node {
47 | let formatter = RSS.dateFormatter
48 | formatter.timeZone = timeZone
49 | let dateString = formatter.string(from: date)
50 | return .element(named: "lastBuildDate", text: dateString)
51 | }
52 |
53 | /// Declare the TTL (or "Time to live") time interval for this feed.
54 | /// - parameter minutes: The number of minutes until the feed expires.
55 | static func ttl(_ minutes: Int) -> Node {
56 | .element(named: "ttl", text: String(minutes))
57 | }
58 |
59 | /// Associate an Atom feed link with this feed.
60 | /// - parameter href: The link of the atom feed (usually the same URL as
61 | /// the feed's own).
62 | static func atomLink(_ href: URLRepresentable) -> Node {
63 | .selfClosedElement(named: "atom:link", attributes: [
64 | .any(name: "href", value: href.string),
65 | .any(name: "rel", value: "self"),
66 | .any(name: "type", value: "application/rss+xml")
67 | ])
68 | }
69 |
70 | /// Add an `- ` element within the current context.
71 | /// - parameter nodes: The element's child elements.
72 | static func item(_ nodes: Node...) -> Node {
73 | .element(named: "item", nodes: nodes)
74 | }
75 | }
76 |
77 | // MARK: - Item
78 |
79 | public extension Node where Context: RSSItemContext {
80 | /// Add a `` element within the current context.
81 | /// - parameter nodes: The element's attributes and child elements.
82 | static func guid(_ nodes: Node...) -> Node {
83 | .element(named: "guid", nodes: nodes)
84 | }
85 |
86 | /// Assign an HTML string as this item's content.
87 | /// - parameter html: The HTML to assign.
88 | static func content(_ html: String) -> Node {
89 | .element(named: "content:encoded",
90 | nodes: [Node.raw("")])
91 | }
92 |
93 | /// Assign this item's HTML content using Plot's DSL.
94 | /// - parameter nodes: The HTML nodes to assign. Will be rendered
95 | /// into a string without any indentation.
96 | static func content(_ nodes: Node...) -> Node {
97 | .content(nodes.render())
98 | }
99 | }
100 |
101 | public extension Node where Context == RSS.ItemContext {
102 | /// Declare this item's title.
103 | /// - parameter title: The title to declare.
104 | static func title(_ title: String) -> Node {
105 | .element(named: "title", text: title)
106 | }
107 | }
108 |
109 | // MARK: - Generic content
110 |
111 | public extension Node where Context: RSSContentContext {
112 | /// Define a decription for the content.
113 | /// - parameter text: The content's description text.
114 | static func description(_ text: String) -> Node {
115 | .element(named: "description", text: text)
116 | }
117 |
118 | /// Define a description for the content as CDATA encoded HTML.
119 | /// - parameter nodes: The HTML nodes to render as a description.
120 | static func description(_ nodes: Node...) -> Node {
121 | .element(named: "description", nodes: [Node.raw("")])
122 | }
123 |
124 | /// Define the content's canonical URL.
125 | /// - parameter url: The content's URL.
126 | static func link(_ url: URLRepresentable) -> Node {
127 | .element(named: "link", text: url.string)
128 | }
129 |
130 | /// Declare which date that the content was published on.
131 | /// - parameter date: The publishing date.
132 | /// - parameter timeZone: The time zone of the given `Date` (default: `.current`).
133 | static func pubDate(_ date: Date,
134 | timeZone: TimeZone = .current) -> Node {
135 | let formatter = RSS.dateFormatter
136 | formatter.timeZone = timeZone
137 | let dateString = formatter.string(from: date)
138 | return .element(named: "pubDate", text: dateString)
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Language.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | // Enum defining language codes according to the ISO 639 standard,
8 | // which can be used to specify the primary language of an HTML,
9 | // RSS or PodcastFeed document.
10 | public enum Language: String {
11 | case abkhazian = "ab"
12 | case afar = "aa"
13 | case afrikaans = "af"
14 | case akan = "ak"
15 | case albanian = "sq"
16 | case amharic = "am"
17 | case arabic = "ar"
18 | case aragonese = "an"
19 | case armenian = "hy"
20 | case assamese = "as"
21 | case auEnglish = "en-au"
22 | case avaric = "av"
23 | case avestan = "ae"
24 | case aymara = "ay"
25 | case azerbaijani = "az"
26 | case bambara = "bm"
27 | case bashkir = "ba"
28 | case basque = "eu"
29 | case belarusian = "be"
30 | case bengali = "bn"
31 | case bihari = "bh"
32 | case bislama = "bi"
33 | case bosnian = "bs"
34 | case breton = "br"
35 | case brPortuguese = "pt-br"
36 | case bulgarian = "bg"
37 | case burmese = "my"
38 | case catalan = "ca"
39 | case caEnglish = "en-ca"
40 | case chamorro = "ch"
41 | case chechen = "ce"
42 | case chichewa, chewa, nyanja = "ny"
43 | case chinese = "zh"
44 | case traditionalChinese = "zh-Hant"
45 | case simplifiedChinese = "zh-Hans"
46 | case chuvash = "cv"
47 | case cornish = "kw"
48 | case corsican = "co"
49 | case cree = "cr"
50 | case croatian = "hr"
51 | case czech = "cs"
52 | case danish = "da"
53 | case divehi, dhivehi, maldivian = "dv"
54 | case dutch = "nl"
55 | case dzongkha = "dz"
56 | case english = "en"
57 | case esperanto = "eo"
58 | case estonian = "et"
59 | case ewe = "ee"
60 | case faroese = "fo"
61 | case fijian = "fj"
62 | case finnish = "fi"
63 | case french = "fr"
64 | case fula, fulah, pulaar, pular = "ff"
65 | case galician = "gl"
66 | case gaelicScottish = "gd"
67 | case georgian = "ka"
68 | case german = "de"
69 | case greek = "el"
70 | case guarani = "gn"
71 | case gujarati = "gu"
72 | case haitianCreole = "ht"
73 | case hausa = "ha"
74 | case hebrew = "he"
75 | case herero = "hz"
76 | case hindi = "hi"
77 | case hiriMotu = "ho"
78 | case hungarian = "hu"
79 | case icelandic = "is"
80 | case ido = "io"
81 | case igbo = "ig"
82 | case indonesian = "id"
83 | case interlingua = "ia"
84 | case interlingue = "ie"
85 | case inuktitut = "iu"
86 | case inupiak = "ik"
87 | case irish = "ga"
88 | case italian = "it"
89 | case japanese = "ja"
90 | case javanese = "jv"
91 | case kalaallisut, greenlandic = "kl"
92 | case kannada = "kn"
93 | case kanuri = "kr"
94 | case kashmiri = "ks"
95 | case kazakh = "kk"
96 | case khmer = "km"
97 | case kikuyu = "ki"
98 | case kinyarwanda = "rw"
99 | case kirundi = "rn"
100 | case kyrgyz = "ky"
101 | case komi = "kv"
102 | case kongo = "kg"
103 | case korean = "ko"
104 | case kurdish = "ku"
105 | case kwanyama = "kj"
106 | case lao = "lo"
107 | case latin = "la"
108 | case latvian = "lv"
109 | case limburgish = "li"
110 | case lingala = "ln"
111 | case lithuanian = "lt"
112 | case lugaKatanga = "lu"
113 | case luganda, ganda = "lg"
114 | case luxembourgish = "lb"
115 | case manx = "gv"
116 | case macedonian = "mk"
117 | case malagasy = "mg"
118 | case malay = "ms"
119 | case malayalam = "ml"
120 | case maltese = "mt"
121 | case maori = "mi"
122 | case marathi = "mr"
123 | case marshallese = "mh"
124 | case moldavian = "mo"
125 | case mongolian = "mn"
126 | case nauru = "na"
127 | case navajo = "nv"
128 | case ndonga = "ng"
129 | case northernNdebele = "nd"
130 | case nepali = "ne"
131 | case norwegian = "no"
132 | case norwegianBokmål = "nb"
133 | case norwegianNynorsk = "nn"
134 | case nuosu, sichuanYi = "ii"
135 | case occitan = "oc"
136 | case ojibwe = "oj"
137 | case oldChurchSlavonic, oldBulgarian = "cu"
138 | case oriya = "or"
139 | case oromo = "om"
140 | case ossetian = "os"
141 | case pāli = "pi"
142 | case pashto, pushto = "ps"
143 | case persian = "fa"
144 | case polish = "pl"
145 | case portuguese = "pt"
146 | case punjabi = "pa"
147 | case quechua = "qu"
148 | case romansh = "rm"
149 | case romanian = "ro"
150 | case russian = "ru"
151 | case sami = "se"
152 | case samoan = "sm"
153 | case sango = "sg"
154 | case sanskrit = "sa"
155 | case serbian = "sr"
156 | case serboCroatian = "sh"
157 | case sesotho = "st"
158 | case setswana = "tn"
159 | case shona = "sn"
160 | case sindhi = "sd"
161 | case sinhalese = "si"
162 | case slovak = "sk"
163 | case slovenian = "sl"
164 | case somali = "so"
165 | case southernNdebele = "nr"
166 | case spanish = "es"
167 | case sundanese = "su"
168 | case swahili = "sw"
169 | case swati = "ss"
170 | case swedish = "sv"
171 | case tagalog = "tl"
172 | case tahitian = "ty"
173 | case tajik = "tg"
174 | case tamil = "ta"
175 | case tatar = "tt"
176 | case telugu = "te"
177 | case thai = "th"
178 | case tibetan = "bo"
179 | case tigrinya = "ti"
180 | case tonga = "to"
181 | case tsonga = "ts"
182 | case turkish = "tr"
183 | case turkmen = "tk"
184 | case twi = "tw"
185 | case uyghur = "ug"
186 | case ukEnglish = "en-gb"
187 | case ukrainian = "uk"
188 | case urdu = "ur"
189 | case usEnglish = "en-us"
190 | case uzbek = "uz"
191 | case venda = "ve"
192 | case vietnamese = "vi"
193 | case volapük = "vo"
194 | case wallon = "wa"
195 | case welsh = "cy"
196 | case wolof = "wo"
197 | case westernFrisian = "fy"
198 | case xDefault = "x-default"
199 | case xhosa = "xh"
200 | case yiddish = "yi, ji"
201 | case yoruba = "yo"
202 | case zhuang, chuang = "za"
203 | case zulu = "zu"
204 | }
205 |
--------------------------------------------------------------------------------
/Tests/PlotTests/Assertions.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import XCTest
8 | import Plot
9 |
10 | func assertEqualHTMLContent(
11 | _ document: HTML,
12 | _ content: String,
13 | file: StaticString = #file,
14 | line: UInt = #line
15 | ) {
16 | let html = document.render()
17 | let expectedPrefix = ""
18 | let expectedSuffix = ""
19 |
20 | XCTAssertTrue(
21 | html.hasPrefix(expectedPrefix),
22 | """
23 | Invalid HTML prefix.
24 | Expected '\(expectedPrefix)'.
25 | Found '\(html.prefix(expectedPrefix.count))'.
26 | """,
27 | file: file,
28 | line: line
29 | )
30 |
31 | XCTAssertTrue(
32 | html.hasSuffix(expectedSuffix),
33 | """
34 | Invalid HTML suffix.
35 | Expected '\(expectedSuffix)'.
36 | Found '\(html.suffix(expectedSuffix.count))'.
37 | """,
38 | file: file,
39 | line: line
40 | )
41 |
42 | let expectedContent = html
43 | .dropFirst(expectedPrefix.count)
44 | .dropLast(expectedSuffix.count)
45 |
46 | XCTAssertEqual(
47 | String(expectedContent),
48 | content,
49 | file: file,
50 | line: line
51 | )
52 | }
53 |
54 | func assertEqualSiteMapContent(
55 | _ document: SiteMap,
56 | _ content: String,
57 | file: StaticString = #file,
58 | line: UInt = #line
59 | ) {
60 | let map = document.render()
61 |
62 | let expectedPrefix = XML().render() + """
63 |
65 | """
66 |
67 | let expectedSuffix = ""
68 |
69 | XCTAssertTrue(
70 | map.hasPrefix(expectedPrefix),
71 | """
72 | Invalid SiteMap prefix.
73 | Expected '\(expectedPrefix)'.
74 | Found '\(map.prefix(expectedPrefix.count))'.
75 | """,
76 | file: file,
77 | line: line
78 | )
79 |
80 | XCTAssertTrue(
81 | map.hasSuffix(expectedSuffix),
82 | """
83 | Invalid SiteMap suffix.
84 | Expected '\(expectedSuffix)'.
85 | Found '\(map.suffix(expectedSuffix.count))'.
86 | """,
87 | file: file,
88 | line: line
89 | )
90 |
91 | let expectedContent = map
92 | .dropFirst(expectedPrefix.count)
93 | .dropLast(expectedSuffix.count)
94 |
95 | XCTAssertEqual(
96 | String(expectedContent),
97 | content,
98 | file: file,
99 | line: line
100 | )
101 | }
102 |
103 | func assertEqualXMLContent(
104 | _ document: XML,
105 | _ content: String,
106 | file: StaticString = #file,
107 | line: UInt = #line
108 | ) {
109 | let xml = document.render()
110 | let declaration = #""#
111 |
112 | XCTAssertTrue(
113 | xml.hasPrefix(declaration),
114 | """
115 | Invalid XML declaration.
116 | Expected '\(declaration)'.
117 | Found '\(xml.prefix(declaration.count))'.
118 | """,
119 | file: file,
120 | line: line
121 | )
122 |
123 | XCTAssertEqual(
124 | String(xml.dropFirst(declaration.count)),
125 | content,
126 | file: file,
127 | line: line
128 | )
129 | }
130 |
131 | func assertEqualPodcastFeedContent(
132 | _ feed: PodcastFeed,
133 | _ content: String,
134 | file: StaticString = #file,
135 | line: UInt = #line
136 | ) {
137 | assertEqualRSSFeedContent(
138 | feed,
139 | content,
140 | type: "podcast",
141 | namespaces: [
142 | ("atom", "http://www.w3.org/2005/Atom"),
143 | ("content", "http://purl.org/rss/1.0/modules/content/"),
144 | ("itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"),
145 | ("media", "http://www.rssboard.org/media-rss")
146 | ],
147 | file: file,
148 | line: line
149 | )
150 | }
151 |
152 | func assertEqualRSSFeedContent(
153 | _ feed: RSS,
154 | _ content: String,
155 | file: StaticString = #file,
156 | line: UInt = #line
157 | ) {
158 | assertEqualRSSFeedContent(
159 | feed,
160 | content,
161 | type: "RSS",
162 | namespaces: [
163 | ("atom", "http://www.w3.org/2005/Atom"),
164 | ("content", "http://purl.org/rss/1.0/modules/content/")
165 | ],
166 | file: file,
167 | line: line
168 | )
169 | }
170 |
171 | private func assertEqualRSSFeedContent(
172 | _ feed: R,
173 | _ content: String,
174 | type: String,
175 | namespaces: [(name: String, url: String)],
176 | file: StaticString,
177 | line: UInt
178 | ) {
179 | let xmlDeclaration = XML().render()
180 |
181 | let namespaces = namespaces.map({ name, url in
182 | "xmlns:\(name)=\"\(url)\""
183 | }).joined(separator: " ")
184 |
185 | let expectedPrefix = "\(xmlDeclaration)"
186 | let expectedSuffix = ""
187 |
188 | let xml = feed.render()
189 |
190 | XCTAssertTrue(
191 | xml.hasPrefix(expectedPrefix),
192 | """
193 | Invalid \(type) feed prefix.
194 | Expected '\(expectedPrefix)'.
195 | Found '\(xml.prefix(expectedPrefix.count))'.
196 | """,
197 | file: file,
198 | line: line
199 | )
200 |
201 | XCTAssertTrue(
202 | xml.hasSuffix(expectedSuffix),
203 | """
204 | \(type.capitalized) feed is not closed with '\(expectedSuffix)'.
205 | Feed: '\(xml)'
206 | """,
207 | file: file,
208 | line: line
209 | )
210 |
211 | let expectedContent = xml
212 | .dropFirst(expectedPrefix.count)
213 | .dropLast(expectedSuffix.count)
214 |
215 | XCTAssertEqual(
216 | String(expectedContent),
217 | content,
218 | file: file,
219 | line: line
220 | )
221 | }
222 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Component.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2021
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// Protocol used to define components that can be rendered into HTML.
10 | ///
11 | /// Implement custom types conforming to this protocol to create your own
12 | /// HTML components that can then be rendered using either the built-in
13 | /// component types that Plot ships with, or using the `Node`-based API.
14 | ///
15 | /// You can freely mix and match components and nodes when implementing
16 | /// a component, and any component can be converted into a `Node`, either
17 | /// by creating a `.component` node, or by calling `convertToNode()`
18 | /// on a component.
19 | ///
20 | /// Modifiers can be applied to components to change attributes like `class`
21 | /// and `id`, and using the `EnvironmentValue` property wrapper and the
22 | /// `EnvironmentKey` type, you can propagate environmental values through
23 | /// a hierarchy of nodes and components.
24 | public protocol Component: Renderable {
25 | /// The underlying component that should be used to render this component.
26 | /// Can either be a `Node`, another `Component`, or a group of components
27 | /// created using the `ComponentGroup` type.
28 | var body: Component { get }
29 | }
30 |
31 | public extension Component {
32 | /// A convenience type alias for a closure that creates the contents of a
33 | /// given component. Closures of this type are typically marked with the
34 | /// `@ComponentBuilder` attribute to enable Plot's DSL to be used when
35 | /// implementing them.
36 | typealias ContentProvider = () -> ComponentGroup
37 |
38 | /// Add an attribute to the HTML element used to render this component.
39 | /// - parameter name: The name of the attribute to add.
40 | /// - parameter value: The value that the attribute should have.
41 | /// - parameter replaceExisting: Whether any existing attribute with the
42 | /// same name should be replaced by the new attribute. Defaults to `true`,
43 | /// and if set to `false`, this attribute's value will instead be appended
44 | /// to any existing one, separated by a space.
45 | /// - parameter ignoreValueIfEmpty: Whether the attribute should be ignored if
46 | /// its value is `nil` or empty. Defaults to `true`, and if set to `false`,
47 | /// only the attribute's name will be rendered if its value is empty.
48 | func attribute(named name: String,
49 | value: String?,
50 | replaceExisting: Bool = true,
51 | ignoreValueIfEmpty: Bool = true) -> Component {
52 | attribute(Attribute(
53 | name: name,
54 | value: value,
55 | replaceExisting: replaceExisting,
56 | ignoreIfValueIsEmpty: ignoreValueIfEmpty
57 | ))
58 | }
59 |
60 | /// Add an attribute to the HTML element used to render this component.
61 | /// - parameter attribute: The attribute to add. See the documentation for
62 | /// the `Attribute` type for more information.
63 | func attribute(_ attribute: Attribute) -> Component {
64 | if let group = self as? ComponentGroup {
65 | return ComponentGroup(members: group.members.map {
66 | $0.attribute(attribute)
67 | })
68 | }
69 |
70 | if var modified = self as? ModifiedComponent {
71 | modified.deferredAttributes.append(attribute)
72 | return modified
73 | }
74 |
75 | return ModifiedComponent(
76 | base: self,
77 | deferredAttributes: [attribute]
78 | )
79 | }
80 |
81 | /// Place a value into the environment used to render this component and any
82 | /// of its child components. An environment value will be passed downwards
83 | /// through a component/node hierarchy until its overridden by another value
84 | /// for the same key.
85 | /// - parameter value: The value to add. Must match the type of the key that
86 | /// it's being added for. This value will override any value that was assigned
87 | /// by a parent component for the same key, or the key's default value.
88 | /// - parameter key: The key to associate the value with. You can either use any
89 | /// of the built-in key definitions that Plot ships with, or define your own.
90 | /// See `EnvironmentKey` for more information.
91 | func environmentValue(_ value: T, key: EnvironmentKey) -> Component {
92 | let override = Environment.Override(key: key, value: value)
93 |
94 | if var modified = self as? ModifiedComponent {
95 | modified.environmentOverrides.append(override)
96 | return modified
97 | }
98 |
99 | return ModifiedComponent(
100 | base: self,
101 | environmentOverrides: [override]
102 | )
103 | }
104 |
105 | /// Convert this component into a `Node`, with either an inferred or explicit
106 | /// context. Use this API when you want to embed a component into a `Node`-based
107 | /// hierarchy. Calling this method is equivalent to creating a `.component` node
108 | /// using this component.
109 | /// - parameter context: The context of the returned node (can typically be
110 | /// inferred by the compiler based on the call site).
111 | func convertToNode(withContext context: T.Type = T.self) -> Node {
112 | .component(self)
113 | }
114 |
115 | func render(indentedBy indentationKind: Indentation.Kind?) -> String {
116 | var renderer = Renderer(indentationKind: indentationKind)
117 | renderer.renderComponent(self)
118 | return renderer.result
119 | }
120 | }
121 |
122 | internal extension Component {
123 | func wrappedInElement(named wrappingElementName: String) -> Component {
124 | wrapped(using: ElementWrapper(
125 | wrappingElementName: wrappingElementName
126 | ))
127 | }
128 |
129 | func wrapped(using wrapper: ElementWrapper) -> Component {
130 | guard !(self is EmptyComponent) else {
131 | return self
132 | }
133 |
134 | if let group = self as? ComponentGroup {
135 | return ComponentGroup(
136 | members: group.members.map {
137 | $0.wrapped(using: wrapper)
138 | }
139 | )
140 | }
141 |
142 | return Node.wrappingComponent(self, using: wrapper)
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Plot Contribution Guide
2 |
3 | Welcome to the *Plot Contribution Guide* — a document that aims to give you all the information you need to contribute to the Plot project. Thanks for your interest in contributing to this project, and hopefully this document will help make it as smooth and enjoyable as possible to do so.
4 |
5 | ## Bugs, feature requests and support
6 |
7 | Plot doesn’t use GitHub issues, so all form of support — whether that’s asking a question, reporting a bug, or discussing a feature request — takes place in Pull Requests.
8 |
9 | The idea behind this workflow is to encourage more people using Plot to dive into the source code and familiarize themselves with how it works, in order to better be able to *self-service* on bugs and issues — hopefully leading to a better and smoother experience for everyone involved.
10 |
11 | ### I found a bug, how do I report it?
12 |
13 | If you find a bug, for example an element or attribute that doesn’t render correctly, here’s the recommended workflow:
14 |
15 | 1. Come up with the simplest code possible that reproduces the issue in isolation.
16 | 2. Write a test case using the code that reproduces the issue. See [Plot’s existing test suite](Tests/PlotTests) for inspiration on how to get started, or [this article](https://www.swiftbysundell.com/basics/unit-testing) if you want to learn some of the basics of unit testing in Swift.
17 | 3. Either fix the bug yourself, or simply submit your failing test as a Pull Request, and we can work on a solution together.
18 |
19 | While doing the above does require a bit of extra work for the person who found the bug, it gives us as a community a very nice starting point for fixing issues — hopefully leading to quicker fixes and a more constructive workflow.
20 |
21 | ### I have an idea for a feature request
22 |
23 | First of all, that’s awesome! Your ideas on how to make Plot better and more powerful are super welcome. Here’s the recommended workflow for feature requests:
24 |
25 | 1. Do some prototyping and come up with a sample implementation of your idea or feature request. Note that this doesn’t have to be a fully working, complete implementation, just something that illustrates the feature/idea and how it could be added to Plot.
26 | 2. Submit your sample implementation as a Pull Request. Use the description field to write down why you think the feature should be added and some initial discussion points.
27 | 3. Together we’ll discuss the feature and your sample implementation, and either accept it as-is, or use it as a starting point for a new implementation, or decide that the idea is not worth implementing as this time.
28 |
29 | ### I have a question that the documentation doesn’t yet answer
30 |
31 | With Plot, the goal is to end up with state of the art documentation that answers most of the questions that both users and developers of the tool might have — and the only way to get there is through continued improvement, with your help. Here’s the recommended workflow for getting your question answered:
32 |
33 | 1. Start by looking through the code. Chances are high that you’ll be able to answer your own question by reading through the implementation, the tests, and the inline code documentation.
34 | 2. If you found out the answer to your question — then don’t stop there. Other people will probably ask themselves the same question at some point, so let’s improve the documentation! Find an appropriate place where your question could’ve been answered by clearer documentation or a better structure (for example this document, or inline in the code), and add the documentation you wish would’ve been there. If you didn’t manage to find an answer (no worries, we're all always learning), then write down your question as a comment — either in the code or in one of the Markdown documents.
35 | 3. Submit your new documentation or your comment as a Pull Request, and we'll work on improving the documentation together.
36 |
37 | ## Project structure
38 |
39 | Plot’s code is structured into two main folders — `API` and `Internal`. Any code that has a public-facing API should go into `API`, and purely internal types and functions should go into `Internal`. Note that you shouldn’t split a type up into two files, but rather find one place for it depending on if it has *any* public-facing components.
40 |
41 | For each document type, for example `HTML`, there are several key files in which APIs are defined:
42 |
43 | - The main document file, `HTML`, defines the format itself as well as all of its `Context` types.
44 | - The elements file, `HTMLElements`, defines all DSL APIs for constructing elements within a document.
45 | - The attributes file, `HTMLAttributes`, defines all DSL APIs for constructing attributes for elements of that format.
46 | - Finally, the components file, `HTMLComponents`, defines higher-level components that are compositions of elements and attributes.
47 |
48 | *The same structure is also used for all other document formats — `XML`, `RSS`, `PodcastFeed`, and `SiteMap`.*
49 |
50 | ## Unit testing
51 |
52 | Plot has 100% unit testing coverage, and the goal is to keep it that way. All changes to Plot should be fully covered with tests, both to make sure that the new implementation works as expected, and to ensure that it keeps working as the code base is being iterated on.
53 |
54 | If you’re new to unit testing and want to learn more about it, then Swift by Sundell has [lots of articles on the topic](https://www.swiftbysundell.com/tags/unit-testing), starting with [this Basics article](https://www.swiftbysundell.com/basics/unit-testing).
55 |
56 | ## Adding a new node type
57 |
58 | If you’ve encountered an element or attribute that’s missing from Plot’s DSL, then feel free to add it. Here’s how to do that:
59 |
60 | 1. Start by finding the file in which your new API should be added. The above [project structure section](#project-structure) should help you identify the file that’d be the most appropriate. For example, HTML elements should be defined within `HTMLElements.swift`, and attributes within `HTMLAttributes.swift`.
61 | 2. Either add your new API to an existing `Node` extension, or create a new one constrained to the type of `Context` that your element or attribute belongs in. For example, if it’s an attribute for an `` HTML element, then it should be defined in the `Node` extension `where Context == HTML.AnchorContext`.
62 | 3. Write a test that verifies that your new API is working. See the above [unit testing section](#unit-testing) for more information.
63 | 4. Submit your new API and your test as a Pull Request.
64 | 5. Your Pull Request will be reviewed by a maintainer as soon as possible.
65 |
66 | ## Conclusion
67 |
68 | Hopefully this document has given you an introduction to how Plot works, both in terms of its recommended project workflow and its structure. Feel free to submit Pull Requests to improve this document, and hope you’ll have a nice experience contributing to Plot.
--------------------------------------------------------------------------------
/Sources/Plot/API/PodcastElements.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | // MARK: - Channel
8 |
9 | public extension Node where Context == PodcastFeed.ChannelContext {
10 | /// Instruct Apple Podcasts that the podcast's feed has moved to a new URL.
11 | /// - parameter url: The feed's new URL.
12 | static func newFeedURL(_ url: URLRepresentable) -> Node {
13 | .element(named: "itunes:new-feed-url", text: url.string)
14 | }
15 |
16 | /// Attach a copyright notice to the feed.
17 | /// - parameter text: The copyright text to attach.
18 | static func copyright(_ text: String) -> Node {
19 | .element(named: "copyright", text: text)
20 | }
21 |
22 | /// Define the podcast's owner.
23 | /// - parameter nodes: The element's child nodes.
24 | static func owner(_ nodes: Node...) -> Node {
25 | .element(named: "itunes:owner", nodes: nodes)
26 | }
27 |
28 | /// Declare the podcast's type.
29 | /// - parameter type: The type to declare. See `PodcastType` for more info.
30 | static func type(_ type: PodcastType) -> Node {
31 | .element(named: "itunes:type", text: type.rawValue)
32 | }
33 | }
34 |
35 | // MARK: - Any content context
36 |
37 | public extension Node where Context: PodcastContentContext {
38 | /// Declare who is the author of the content.
39 | /// - parameter name: The name of the author.
40 | static func author(_ name: String) -> Node {
41 | .element(named: "itunes:author", text: name)
42 | }
43 |
44 | /// Define a subtitle for the content.
45 | /// - parameter text: The content's subtitle.
46 | static func subtitle(_ text: String) -> Node {
47 | .element(named: "itunes:subtitle", text: text)
48 | }
49 |
50 | /// Define a summary text for the content.
51 | /// - parameter text: The content's summary text.
52 | static func summary(_ text: String) -> Node {
53 | .element(named: "itunes:summary", text: text)
54 | }
55 |
56 | /// Declare whether the content is explicit.
57 | ///
58 | /// See Apple Podcast's guidelines as to what kind of content is
59 | /// considered explicit, and should be marked with this flag.
60 | ///
61 | /// - parameter isExplicit: Whether the content is explicit.
62 | static func explicit(_ isExplicit: Bool) -> Node {
63 | .element(named: "itunes:explicit",
64 | text: isExplicit ? "yes" : "no")
65 | }
66 |
67 | /// Associate an image with the content.
68 | /// - parameter url: The URL of the content's image.
69 | static func image(_ url: URLRepresentable) -> Node {
70 | .selfClosedElement(
71 | named: "itunes:image",
72 | attributes: [.any(name: "href", value: url.string)]
73 | )
74 | }
75 | }
76 |
77 | // MARK: - Owner
78 |
79 | public extension Node where Context == PodcastFeed.OwnerContext {
80 | /// Define the owner's name.
81 | /// - parameter name: The owner's name.
82 | static func name(_ name: String) -> Node {
83 | .element(named: "itunes:name", text: name)
84 | }
85 |
86 | /// Define the owner's email address.
87 | /// - parameter email: The owner's email address.
88 | static func email(_ email: String) -> Node {
89 | .element(named: "itunes:email", text: email)
90 | }
91 | }
92 |
93 | // MARK: - Categories
94 |
95 | public extension Node where Context: PodcastCategoryContext {
96 | /// Define the category that the podcast belongs to.
97 | /// - parameter name: The name of the category.
98 | /// - parameter subcategory: Any optional, more specific subcategory.
99 | static func category(_ name: String, _ subcategory: Node = .empty) -> Node {
100 | .element(Element(name: "itunes:category", closingMode: .selfClosing, nodes: [
101 | .attribute(named: "text", value: name),
102 | subcategory
103 | ]))
104 | }
105 | }
106 |
107 | // MARK: - Items (episodes)
108 |
109 | public extension Node where Context == PodcastFeed.ItemContext {
110 | /// Define the episode's title.
111 | /// - parameter title: The title to define.
112 | static func title(_ title: String) -> Node {
113 | .group(
114 | .element(named: "title", text: title),
115 | .element(named: "itunes:title", text: title)
116 | )
117 | }
118 |
119 | /// Define the duration of the episode as a string.
120 | ///
121 | /// Consider using the more type-safe `hours:minutes:seconds:` variant
122 | /// if you're defining a duration in code.
123 | ///
124 | /// - parameter string: A string that describes the episode's duration.
125 | /// Should be specified in the HH:mm:ss format.
126 | static func duration(_ string: String) -> Node {
127 | .element(named: "itunes:duration", text: string)
128 | }
129 |
130 | /// Define the duration of the episode.
131 | /// - parameter hours: The number of hours.
132 | /// - parameter minutes: The number of minutes.
133 | /// - parameter seconds: The number of seconds.
134 | static func duration(hours: Int, minutes: Int, seconds: Int) -> Node {
135 | func wrap(_ number: Int) -> String {
136 | number < 10 ? "0\(number)" : String(number)
137 | }
138 |
139 | return .duration("\(wrap(hours)):\(wrap(minutes)):\(wrap(seconds))")
140 | }
141 |
142 | /// Define which season that the episode belongs to.
143 | /// - parameter number: The number of the episode's season.
144 | static func seasonNumber(_ number: Int) -> Node {
145 | .element(named: "itunes:season", text: String(number))
146 | }
147 |
148 | /// Define the episode's number.
149 | /// - parameter number: The episode number.
150 | static func episodeNumber(_ number: Int) -> Node {
151 | .element(named: "itunes:episode", text: String(number))
152 | }
153 |
154 | /// Define the episode's type.
155 | /// - parameter type: The type of the episode. See `PodcastEpisodeType`.
156 | static func episodeType(_ type: PodcastEpisodeType) -> Node {
157 | .element(named: "itunes:episodeType", text: type.rawValue)
158 | }
159 |
160 | /// Define an enclosure for the episode's media files.
161 | /// - parameter attributes: The element's attributes.
162 | static func enclosure(_ attributes: Attribute...) -> Node {
163 | .selfClosedElement(named: "enclosure", attributes: attributes)
164 | }
165 |
166 | /// Define the episode's media content metadata.
167 | /// - parameter nodes: The element's child elements and attributes.
168 | static func mediaContent(_ nodes: Node...) -> Node {
169 | .element(named: "media:content", nodes: nodes)
170 | }
171 | }
172 |
173 | // MARK: - Media
174 |
175 | public extension Node where Context == PodcastFeed.MediaContext {
176 | /// Assign an URL from which the media's file can be downloaded.
177 | /// - parameter url: The URL to assign.
178 | static func url(_ url: URLRepresentable) -> Node {
179 | .attribute(named: "url", value: url.string)
180 | }
181 |
182 | /// Assign a length to the media item, in terms of its file size.
183 | /// - parameter byteCount: The file's size in bytes.
184 | static func length(_ byteCount: Int) -> Node {
185 | .attribute(named: "length", value: String(byteCount))
186 | }
187 |
188 | /// Assign a MIME type to the media item.
189 | /// - parameter mimeType: The MIME type to assign.
190 | static func type(_ mimeType: String) -> Node {
191 | .attribute(named: "type", value: mimeType)
192 | }
193 |
194 | /// Define whether the media item is the default one for this episode.
195 | /// - parameter isDefault: Whether this is the default media item.
196 | static func isDefault(_ isDefault: Bool) -> Node {
197 | .attribute(named: "isDefault", value: String(isDefault))
198 | }
199 |
200 | /// Define the type of this media item.
201 | /// - parameter type: The type of the item. See `PodcastMediaType`.
202 | static func medium(_ type: PodcastMediaType) -> Node {
203 | .attribute(named: "medium", value: type.rawValue)
204 | }
205 |
206 | /// Define the media item's title (usually the episode's title).
207 | /// - Parameter title: The title to define.
208 | static func title(_ title: String) -> Node {
209 | .element(named: "media:title", nodes: [
210 | .attribute(named: "type", value: "plain"),
211 | Node.text(title)
212 | ])
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/Tests/PlotTests/PodcastFeedTests.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import XCTest
8 | import Plot
9 |
10 | final class PodcastFeedTests: XCTestCase {
11 | func testEmptyFeed() {
12 | let feed = PodcastFeed()
13 | assertEqualPodcastFeedContent(feed, "")
14 | }
15 |
16 | func testNewFeedURL() {
17 | let feed = PodcastFeed(.newFeedURL("url.com"))
18 | assertEqualPodcastFeedContent(feed, "url.com")
19 | }
20 |
21 | func testPodcastTitle() {
22 | let feed = PodcastFeed(.title("MyPodcast"))
23 | assertEqualPodcastFeedContent(feed, "MyPodcast")
24 | }
25 |
26 | func testPodcastSubtitle() {
27 | let feed = PodcastFeed(.subtitle("Subtitle"))
28 | assertEqualPodcastFeedContent(feed, "Subtitle")
29 | }
30 |
31 | func testPodcastDescription() {
32 | let feed = PodcastFeed(.description("Description"))
33 | assertEqualPodcastFeedContent(feed, "Description")
34 | }
35 |
36 | func testPodcastSummary() {
37 | let feed = PodcastFeed(.summary("Summary"))
38 | assertEqualPodcastFeedContent(feed, "Summary")
39 | }
40 |
41 | func testPodcastURL() {
42 | let feed = PodcastFeed(.link("url.com"))
43 | assertEqualPodcastFeedContent(feed, "url.com")
44 | }
45 |
46 | func testPodcastAtomLink() {
47 | let feed = PodcastFeed(.atomLink("url.com"))
48 | assertEqualPodcastFeedContent(feed, """
49 |
50 | """)
51 | }
52 |
53 | func testPodcastLanguage() {
54 | let feed = PodcastFeed(.language(.usEnglish))
55 | assertEqualPodcastFeedContent(feed, "en-us")
56 | }
57 |
58 | func testPodcastTTL() {
59 | let feed = PodcastFeed(.ttl(200))
60 | assertEqualPodcastFeedContent(feed, "200")
61 | }
62 |
63 | func testPodcastCopyright() {
64 | let feed = PodcastFeed(.copyright("Copyright"))
65 | assertEqualPodcastFeedContent(feed, "Copyright")
66 | }
67 |
68 | func testPodcastAuthor() {
69 | let feed = PodcastFeed(.author("Author"))
70 | assertEqualPodcastFeedContent(feed, "Author")
71 | }
72 |
73 | func testPodcastExplicitFlag() {
74 | let explicitFeed = PodcastFeed(.explicit(true))
75 | assertEqualPodcastFeedContent(explicitFeed, "yes")
76 |
77 | let nonExplicitFeed = PodcastFeed(.explicit(false))
78 | assertEqualPodcastFeedContent(nonExplicitFeed, "no")
79 | }
80 |
81 | func testPodcastOwner() {
82 | let feed = PodcastFeed(.owner(.name("Name"), .email("Email")))
83 | assertEqualPodcastFeedContent(feed, """
84 | NameEmail
85 | """)
86 | }
87 |
88 | func testPodcastCategory() {
89 | let feed = PodcastFeed(.category("News"))
90 | assertEqualPodcastFeedContent(feed, #""#)
91 | }
92 |
93 | func testPodcastSubcategory() {
94 | let feed = PodcastFeed(.category("News", .category("Tech News")))
95 | assertEqualPodcastFeedContent(feed, """
96 |
97 | """)
98 | }
99 |
100 | func testPodcastType() {
101 | let episodicFeed = PodcastFeed(.type(.episodic))
102 | assertEqualPodcastFeedContent(episodicFeed, "episodic")
103 |
104 | let serialFeed = PodcastFeed(.type(.serial))
105 | assertEqualPodcastFeedContent(serialFeed, "serial")
106 | }
107 |
108 | func testPodcastImage() {
109 | let feed = PodcastFeed(.image("image.png"))
110 | assertEqualPodcastFeedContent(feed, #""#)
111 | }
112 |
113 | func testPodcastPublicationDate() throws {
114 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
115 | let feed = PodcastFeed(.pubDate(stubs.date, timeZone: stubs.timeZone))
116 | assertEqualPodcastFeedContent(feed, "\(stubs.expectedString)")
117 | }
118 |
119 | func testPodcastLastBuildDate() throws {
120 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
121 | let feed = PodcastFeed(.lastBuildDate(stubs.date, timeZone: stubs.timeZone))
122 | assertEqualPodcastFeedContent(feed, "\(stubs.expectedString)")
123 | }
124 |
125 | func testEpisodeGUID() {
126 | let guidFeed = PodcastFeed(.item(.guid("123")))
127 | assertEqualPodcastFeedContent(guidFeed, "
- 123
")
128 |
129 | let permaLinkFeed = PodcastFeed(.item(.guid("url.com", .isPermaLink(true))))
130 | assertEqualPodcastFeedContent(permaLinkFeed, """
131 | - url.com
132 | """)
133 |
134 | let nonPermaLinkFeed = PodcastFeed(.item(.guid("123", .isPermaLink(false))))
135 | assertEqualPodcastFeedContent(nonPermaLinkFeed, """
136 | - 123
137 | """)
138 | }
139 |
140 | func testEpisodeTitle() {
141 | let feed = PodcastFeed(.item(.title("Title")))
142 | assertEqualPodcastFeedContent(feed, """
143 | - TitleTitle
144 | """)
145 | }
146 |
147 | func testEpisodeDescription() {
148 | let feed = PodcastFeed(.item(.description("Description")))
149 | assertEqualPodcastFeedContent(feed, """
150 | - Description
151 | """)
152 | }
153 |
154 | func testEpisodeURL() {
155 | let feed = PodcastFeed(.item(.link("url.com")))
156 | assertEqualPodcastFeedContent(feed, "- url.com
")
157 | }
158 |
159 | func testEpisodePublicationDate() throws {
160 | let stubs = try Date.makeStubs(withFormattingStyle: .rss)
161 | let feed = PodcastFeed(.item(.pubDate(stubs.date, timeZone: stubs.timeZone)))
162 | assertEqualPodcastFeedContent(feed, """
163 | - \(stubs.expectedString)
164 | """)
165 | }
166 |
167 | func testEpisodeDuration() {
168 | let feed = PodcastFeed(.item(
169 | .duration("00:15:12"),
170 | .duration(hours: 0, minutes: 15, seconds: 12),
171 | .duration(hours: 1, minutes: 2, seconds: 3)
172 | ))
173 |
174 | assertEqualPodcastFeedContent(feed, """
175 | - \
176 | 00:15:12\
177 | 00:15:12\
178 | 01:02:03\
179 |
180 | """)
181 | }
182 |
183 | func testSeasonNumber() {
184 | let feed = PodcastFeed(.item(.seasonNumber(3)))
185 | assertEqualPodcastFeedContent(feed, """
186 | - 3
187 | """)
188 | }
189 |
190 | func testEpisodeNumber() {
191 | let feed = PodcastFeed(.item(.episodeNumber(42)))
192 | assertEqualPodcastFeedContent(feed, """
193 | - 42
194 | """)
195 | }
196 |
197 | func testEpisodeType() {
198 | let feed = PodcastFeed(
199 | .item(.episodeType(.full)),
200 | .item(.episodeType(.trailer)),
201 | .item(.episodeType(.bonus))
202 | )
203 |
204 | assertEqualPodcastFeedContent(feed, """
205 | - full
\
206 | - trailer
\
207 | - bonus
208 | """)
209 | }
210 |
211 | func testEpisodeAudio() {
212 | let feed = PodcastFeed(.item(.audio(
213 | url: "episode.mp3",
214 | byteSize: 69121733,
215 | title: "Episode"
216 | )))
217 |
218 | let expectedComponents = [
219 | "- ",
220 | #""#,
221 | #""#,
222 | #"Episode"#,
223 | "",
224 | "
"
225 | ]
226 |
227 | assertEqualPodcastFeedContent(feed, expectedComponents.joined())
228 | }
229 |
230 | func testEpisodeHTMLContent() {
231 | let feed = PodcastFeed(.item(.content(
232 | "Hello
World & Everyone!
"
233 | )))
234 |
235 | let expectedComponents = [
236 | "- ",
237 | "",
238 | "Hello
World & Everyone!
]]>",
239 | "",
240 | " "
241 | ]
242 |
243 | assertEqualPodcastFeedContent(feed, expectedComponents.joined())
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/Sources/Plot/API/Node.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of a node within a document's hierarchy.
10 | ///
11 | /// Plot treats all elements and attributes that a document contains
12 | /// as nodes. When using the Plot DSL, each time you create a new
13 | /// element, or add an attribute to an existing one, you are creating
14 | /// a node. Nodes can also contain just text, which can either be
15 | /// escaped or treated as raw, pre-processed text. Groups can also be
16 | /// created to form components.
17 | public struct Node {
18 | private let rendering: (inout Renderer) -> Void
19 | }
20 |
21 | public extension Node {
22 | /// An empty node, which won't be rendered.
23 | static var empty: Node { Node { _ in } }
24 |
25 | /// Create a node from a raw piece of text that should be rendered as-is.
26 | /// - parameter text: The raw text that the node should contain.
27 | static func raw(_ text: String) -> Node {
28 | Node { $0.renderRawText(text) }
29 | }
30 |
31 | /// Create a node from a piece of free-form text that should be escaped.
32 | /// - parameter text: The text that the node should contain.
33 | static func text(_ text: String) -> Node {
34 | Node { $0.renderText(text) }
35 | }
36 |
37 | /// Create a node representing an element
38 | /// - parameter element: The element that the node should contain.
39 | static func element(_ element: Element) -> Node {
40 | Node { $0.renderElement(element) }
41 | }
42 |
43 | /// Create a custom element with a given name.
44 | /// - parameter name: The name of the element to create.
45 | static func element(named name: String) -> Node {
46 | .element(Element(name: name, nodes: []))
47 | }
48 |
49 | /// Create a custom element with a given name and an array of child nodes.
50 | /// - parameter name: The name of the element to create.
51 | /// - parameter nodes: The nodes (child elements + attributes) to add to the element.
52 | static func element(named name: String, nodes: [Node]) -> Node {
53 | .element(Element(name: name, nodes: nodes))
54 | }
55 |
56 | /// Create a custom element with a given name and an array of child nodes.
57 | /// - parameter name: The name of the element to create.
58 | /// - parameter nodes: The nodes (child elements + attributes) to add to the element.
59 | static func element(named name: String, nodes: [Node]) -> Node {
60 | .element(Element(name: name, nodes: nodes))
61 | }
62 |
63 | /// Create a custom element with a given name and text content.
64 | /// - parameter name: The name of the element to create.
65 | /// - parameter text: The text to use as the node's content.
66 | static func element(named name: String, text: String) -> Node {
67 | .element(Element(name: name, nodes: [Node.text(text)]))
68 | }
69 |
70 | /// Create a custom element with a given name and an array of attributes.
71 | /// - parameter name: The name of the element to create.
72 | /// - parameter attributes: The attributes to add to the element.
73 | static func element(named name: String, attributes: [Attribute]) -> Node {
74 | .element(Element(name: name, nodes: attributes.map(\.node)))
75 | }
76 |
77 | /// Create a custom element with a given name and an array of attributes.
78 | /// - parameter name: The name of the element to create.
79 | /// - parameter attributes: The attributes to add to the element.
80 | static func element(named name: String, attributes: [Attribute]) -> Node {
81 | .element(Element(name: name, nodes: attributes.map(\.node)))
82 | }
83 |
84 | /// Create a custom self-closed element with a given name.
85 | /// - parameter name: The name of the element to create.
86 | static func selfClosedElement(named name: String) -> Node {
87 | .element(Element(name: name, closingMode: .selfClosing, nodes: []))
88 | }
89 |
90 | /// Create a custom self-closed element with a given name and an array of attributes.
91 | /// - parameter name: The name of the element to create.
92 | /// - parameter attributes: The attributes to add to the element.
93 | static func selfClosedElement(named name: String, attributes: [Attribute]) -> Node {
94 | .element(Element(name: name, closingMode: .selfClosing, nodes: attributes.map(\.node)))
95 | }
96 |
97 | /// Create a custom self-closed element with a given name and an array of attributes.
98 | /// - parameter name: The name of the element to create.
99 | /// - parameter attributes: The attributes to add to the element.
100 | static func selfClosedElement(named name: String, attributes: [Attribute]) -> Node {
101 | .element(Element(name: name, closingMode: .selfClosing, nodes: attributes.map(\.node)))
102 | }
103 |
104 | /// Create a node that represents an attribute.
105 | /// - parameter attribute: The attribute that the node should contain.
106 | static func attribute(_ attribute: Attribute) -> Node {
107 | Node { $0.renderAttribute(attribute) }
108 | }
109 |
110 | /// Create a custom attribute with a given name.
111 | /// - parameter name: The name of the attribute to create.
112 | static func attribute(named name: String) -> Node {
113 | .attribute(Attribute(
114 | name: name,
115 | value: nil,
116 | ignoreIfValueIsEmpty: false
117 | ))
118 | }
119 |
120 | /// Create a custom attribute with a given name and value.
121 | /// - parameter name: The name of the attribute to create.
122 | /// - parameter value: The attribute's value.
123 | /// - parameter ignoreIfValueIsEmpty: Whether the attribute should be ignored if
124 | /// its value is empty (default: true).
125 | static func attribute(named name: String,
126 | value: String?,
127 | ignoreIfValueIsEmpty: Bool = true) -> Node {
128 | .attribute(Attribute(
129 | name: name,
130 | value: value,
131 | ignoreIfValueIsEmpty: ignoreIfValueIsEmpty
132 | ))
133 | }
134 |
135 | /// Create a group of nodes from an array.
136 | /// - parameter members: The nodes that should be included in the group.
137 | static func group(_ members: [Node]) -> Node {
138 | Node { renderer in
139 | members.forEach { $0.render(into: &renderer) }
140 | }
141 | }
142 |
143 | /// Create a group of nodes using variadic parameter syntax.
144 | /// - parameter members: The nodes that should be included in the group.
145 | static func group(_ members: Node...) -> Node {
146 | .group(members)
147 | }
148 |
149 | /// Create a node that wraps a `Component`. You can use this API to
150 | /// integrate a component into a `Node`-based hierarchy.
151 | /// - parameter component: The component that should be wrapped.
152 | static func component(_ component: Component) -> Node {
153 | Node { $0.renderComponent(component) }
154 | }
155 |
156 | /// Create a node that wraps a set of components defined within a closure. You
157 | /// can use this API to integrate a group of components into a `Node`-based hierarchy.
158 | /// - parameter content: A closure that creates a group of components.
159 | static func components(@ComponentBuilder _ content: () -> Component) -> Node {
160 | .component(content())
161 | }
162 | }
163 |
164 | internal extension Node where Context: DocumentFormat {
165 | static func document(_ document: Document) -> Node {
166 | Node { renderer in
167 | document.elements.forEach {
168 | renderer.renderElement($0)
169 | }
170 | }
171 | }
172 | }
173 |
174 | internal extension Node where Context == Any {
175 | static func modifiedComponent(_ component: ModifiedComponent) -> Node {
176 | Node { renderer in
177 | renderer.renderComponent(component.base,
178 | deferredAttributes: component.deferredAttributes + renderer.deferredAttributes,
179 | environmentOverrides: component.environmentOverrides
180 | )
181 | }
182 | }
183 |
184 | static func components(_ components: [Component]) -> Node {
185 | Node { renderer in
186 | components.forEach {
187 | renderer.renderComponent($0,
188 | deferredAttributes: renderer.deferredAttributes
189 | )
190 | }
191 | }
192 | }
193 |
194 | static func wrappingComponent(
195 | _ component: Component,
196 | using wrapper: ElementWrapper
197 | ) -> Node {
198 | Node { renderer in
199 | var wrapper = wrapper
200 | wrapper.deferredAttributes = renderer.deferredAttributes
201 |
202 | renderer.renderComponent(component,
203 | elementWrapper: wrapper
204 | )
205 | }
206 | }
207 | }
208 |
209 | extension Node: NodeConvertible {
210 | public var node: Self { self }
211 |
212 | public func render(indentedBy indentationKind: Indentation.Kind?) -> String {
213 | Renderer.render(self, indentedBy: indentationKind)
214 | }
215 | }
216 |
217 | extension Node: Component {
218 | public var body: Component { self }
219 | }
220 |
221 | extension Node: ExpressibleByStringInterpolation {
222 | public init(stringLiteral value: String) {
223 | self = .text(value)
224 | }
225 | }
226 |
227 | extension Node: AnyNode {
228 | func render(into renderer: inout Renderer) {
229 | rendering(&renderer)
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/Sources/Plot/API/HTML.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Plot
3 | * Copyright (c) John Sundell 2019
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Foundation
8 |
9 | /// A representation of an HTML document. Create an instance of this
10 | /// type to build a web page using Plot's type-safe DSL, and then
11 | /// call the `render()` method to turn it into an HTML string.
12 | public struct HTML: DocumentFormat {
13 | private let document: Document
14 | private var environmentOverrides = [Environment.Override]()
15 |
16 | /// Create an HTML document with a collection of nodes that make
17 | /// up its elements and attributes. Start by specifying its root
18 | /// nodes, such as `.head()` and `.body()`, and then create any
19 | /// sort of hierarchy of elements and attributes from there.
20 | /// - parameter nodes: The root nodes of the document, which will
21 | /// be placed inside of an `` element.
22 | public init(_ nodes: Node...) {
23 | document = Document(elements: [
24 | .doctype("html"),
25 | .html(.group(nodes))
26 | ])
27 | }
28 | }
29 |
30 | public extension HTML {
31 | /// Create an HTML document with a set of `` nodes and a closure
32 | /// that defines the components that should make up its ``.
33 | /// - parameter head: The nodes that should be placed within this HTML
34 | /// document's `` element.
35 | /// - parameter body: A closure that defines a set of components that
36 | /// should be placed within this HTML document's `` element.
37 | init(head: [Node] = [],
38 | @ComponentBuilder body: @escaping () -> Component) {
39 | self.init(
40 | .if(!head.isEmpty, .head(.group(head))),
41 | .body(body)
42 | )
43 | }
44 |
45 | /// Place a value into the environment used to render this HTML document and
46 | /// any components within it. An environment value will be passed downwards
47 | /// through a component/node hierarchy until its overriden by another value
48 | /// for the same key.
49 | /// - parameter value: The value to add. Must match the type of the key that
50 | /// it's being added for. This value will override any value that was assigned
51 | /// by a parent component for the same key, or the key's default value.
52 | /// - parameter key: The key to associate the value wth. You can either use any
53 | /// of the built-in key definitions that Plot ships with, or define your own.
54 | /// See `EnvironmentKey` for more information.
55 | func environmentValue(_ value: T, key: EnvironmentKey) -> HTML {
56 | var html = self
57 | html.environmentOverrides.append(.init(key: key, value: value))
58 | return html
59 | }
60 | }
61 |
62 | extension HTML: NodeConvertible {
63 | public var node: Node {
64 | if environmentOverrides.isEmpty {
65 | return document.node
66 | }
67 |
68 | return ModifiedComponent(
69 | base: document.node,
70 | environmentOverrides: environmentOverrides
71 | )
72 | .convertToNode()
73 | }
74 | }
75 |
76 | public extension HTML {
77 | /// The root context of an HTML document. Plot automatically
78 | /// creates all required elements within this context for you.
79 | enum RootContext {}
80 | /// The user-facing root context of an HTML document. Elements
81 | /// like `` and `` are placed within this context.
82 | enum DocumentContext: HTMLStylableContext {}
83 | /// The context within an HTML document's `` element.
84 | enum HeadContext: HTMLContext, HTMLScriptableContext {}
85 | /// The context within an HTML document's `` element.
86 | class BodyContext: HTMLStylableContext, HTMLScriptableContext, HTMLImageContainerContext, HTMLDividableContext {}
87 | /// The context within an HTML `` element.
88 | final class AnchorContext: BodyContext, HTMLLinkableContext {}
89 | /// The context within an HTML `