Plot

Swift Package Manager Mac + Linux Twitter: @johnsundell

欢迎来到 Plot,这是一种领域特定语言 (DSL),用于在 Swift 中编写类型安全的 HTML、XML 和 RSS。它可用作构建网站、文档和 feed,用作模板工具,或用作更高级别组件和工具的渲染器。它的主要重点是静态站点生成和基于 Swift 的 Web 开发。

Plot 用于构建和渲染 swiftbysundell.com 的所有内容。

用 Swift 编写 HTML!

Plot 使您能够使用原生、完全编译的 Swift 代码编写 HTML,方法是将 HTML5 标准的各种元素建模为 Swift API。其结果是一个非常轻量级的 DSL,可让您以高度表达的方式构建完整的网页

let html = HTML(
    .head(
        .title("My website"),
        .stylesheet("styles.css")
    ),
    .body(
        .div(
            .h1("My website"),
            .p("Writing HTML in Swift is pretty great!")
        )
    )
)

乍一看,上面的示例可能看起来 Plot 只是将每个函数调用直接映射到等效的 HTML 元素 — 虽然 *某些* 元素是这种情况,但 Plot 还会自动插入许多非常有价值的元数据。例如,上面的表达式将生成以下 HTML

<!DOCTYPE html>
<html>
    <head>
        <title>My website</title>
        <meta name="twitter:title" content="My website"/>
        <meta property="og:title" content="My website"/>
        <link rel="stylesheet" href="styles.css" type="text/css"/>
    </head>
    <body>
        <div>
            <h1>My website</h1>
            <p>Writing HTML in Swift is pretty great!</p>
        </div>
    </body>
</html>

如上所示,Plot 添加了加载请求的 CSS 样式表所需的所有属性,以及页面标题的附加元数据 — 从而改善页面渲染、社交媒体分享和搜索引擎优化。

Plot 附带了对 HTML5 标准的非常广泛的覆盖,使您能够使用相同的轻量级语法定义各种元素 — 例如表格、列表和内联文本样式

let html = HTML(
    .body(
        .h2("Countries and their capitals"),
        .table(
            .tr(.th("Country"), .th("Capital")),
            .tr(.td("Sweden"), .td("Stockholm")),
            .tr(.td("Japan"), .td("Tokyo"))
        ),
        .h2("List of ", .strong("programming languages")),
        .ul(
            .li("Swift"),
            .li("Objective-C"),
            .li("C")
        )
    )
)

上面我们还使用了 Plot 强大的组合功能,通过简单地添加新的元素作为逗号分隔的值,可以表达各种 HTML 层次结构。

应用属性

属性也可以以与添加子元素的完全相同的方式应用,只需将另一个条目添加到元素的逗号分隔的内容列表中即可。例如,以下是如何定义具有 CSS 类和 URL 的锚元素

let html = HTML(
    .body(
        .a(.class("link"), .href("https://github.com"), "GitHub")
    )
)

属性、元素和内联文本都以相同的方式定义,这使得 Plot 的 API 更易于学习,并且还能够实现真正快速流畅的键入体验 — 因为您只需在任何上下文中键入 . 即可继续定义新的属性和元素。

内置类型安全

Plot 大量使用 Swift 的高级泛型功能,不仅使能够使用原生代码编写 HTML 和 XML,而且还使该过程完全类型安全。

Plot 的所有元素和属性都实现为上下文绑定的节点,这既强制执行有效的 HTML 语义,又使 Xcode 和其他 IDE 能够在使用 Plot 的 DSL 编写代码时提供丰富的自动完成建议。

例如,上面将 href 属性添加到了 <a> 元素,这是完全有效的。但是,如果我们尝试将相同的属性添加到 <p> 元素,我们将收到编译器错误

let html = HTML(.body(
    // Compiler error: Referencing static method 'href' on
    // 'Node' requires that 'HTML.BodyContext' conform to
    // 'HTMLLinkableContext'.
    .p(.href("https://github.com"))
))

Plot 还利用 Swift 类型系统来验证每个文档的元素结构。例如,在 HTML 列表(例如 <ol><ul>)中,只能放置 <li> 元素 — 如果我们违反该规则,我们将再次收到编译器错误

let html = HTML(.body(
    // Compiler error: Member 'p' in 'Node<HTML.ListContext>'
    // produces result of type 'Node<Context>', but context
    // expects 'Node<HTML.ListContext>'.
    .ul(.p("Not allowed"))
))

这种高度的类型安全既带来了非常愉快的开发体验,又使得使用 Plot 创建的 HTML 和 XML 文档具有更高的语义正确性 — 尤其是与使用原始字符串编写文档和标记相比。

组件

Plot 的 Component 协议使您能够使用非常类似于 SwiftUI 的 API 定义和渲染更高级别的组件。创建 HTML 文档时可以混合使用基于 NodeComponent 的元素,从而使您可以灵活地选择哪种方式来实现网站或文档的哪个部分。

例如,假设我们正在使用 Plot 构建一个新闻网站,并且我们想在几个不同的位置渲染新闻文章。以下是如何定义可重用的 NewsArticle 组件,该组件反过来使用一系列内置的 HTML 组件来渲染其 UI

struct NewsArticle: Component {
    var imagePath: String
    var title: String
    var description: String

    var body: Component {
        Article {
            Image(url: imagePath, description: "Header image")
            H1(title)
            Span(description).class("description")
        }
        .class("news")
    }
}

如上面的示例所示,还可以将修饰符应用于组件以设置属性的值,例如 classid

然后,要将上面的组件集成到基于 Node 的层次结构中,我们可以使用 .component API 将其简单地包装在 Node 中,如下所示

func newsArticlePage(for article: NewsArticle) -> HTML {
    return HTML(.body(
        .div(
            .class("wrapper"),
            .component(article)
        )
    ))
}

您还可以直接在组件的 body 中内联基于 Node 的元素,这使您可以完全自由地混合和匹配两个 API

struct Banner: Component {
    var title: String
    var imageURL: URLRepresentable

    var body: Component {
        Div {
            Node.h2(.text(title))
            Image(imageURL)
        }
        .class("banner")
    }
}

强烈建议您在使用 Plot 构建网站和文档时尽可能多地使用上述基于组件的方法 — 因为这样做可以使您构建一个不断增长的可重用组件库,这很可能会随着时间的推移加快您的整体工作流程。

但是请注意,Component API 目前只能用于定义出现在 HTML 页面的 <body> 中的元素。对于 <head> 元素或非 HTML 元素,必须始终使用基于 Node 的 API。

另一个重要的注意事项是,虽然 Plot 在各个方面都经过了大量优化,但与基于 Node 的元素相比,基于 Component 的元素确实需要一些额外的处理 — 因此,在需要最大性能的情况下,您可能需要坚持使用基于 Node 的 API。

使用组件环境

与 SwiftUI 视图一样,Plot 组件可以使用环境 API 通过层次结构向下传递值。使用 EnvironmentKeyenvironmentValue 修饰符将值输入到环境后,可以通过在 Component 实现中定义标记有 @EnvironmentValue 属性的属性来检索该值。

在以下示例中,环境 API 用于使 Page 组件能够将给定的 class 分配给出现在其层次结构中的所有 ActionButton 组件

// We start by defining a custom environment key that can be
// used to enter String values into the environment:
extension EnvironmentKey where Value == String {
    static var actionButtonClass: Self {
        Self(defaultValue: "action-button")
    }
}

struct Page: Component {
    var body: Component {
        Div {
            InfoView(title: "...", text: "...")
        }
        // Here we enter a custom action button class
        // into the environment, which will apply to
        // all child components within our above Div:
        .environmentValue("action-button-large",
            key: .actionButtonClass
        )
    }
}

// Our info view doesn't have to have any awareness of
// our environment value. Plot will automatically pass
// it down to the action buttons defined below:
struct InfoView: Component {
    var title: String
    var text: String

    var body: Component {
        Div {
            H2(title)
            Paragraph(text)
            ActionButton(title: "OK")
            ActionButton(title: "Cancel")
        }
        .class("info-view")
    }
}

struct ActionButton: Component {
    var title: String

    // Here we pick up the current environment value for
    // our custom "actionButtonClass" key, which in this
    // example will be the value that our "Page" component
    // entered into the environment:
    @EnvironmentValue(.actionButtonClass) var className

    var body: Component {
        Button(title).class(className)
    }
}

Plot 还附带了几个组件,这些组件利用环境 API 进行自定义。例如,您可以使用 listStyle 键/修饰符更改层次结构中所有 List 组件的样式,而 linkRelationship 键/修饰符使您可以调整层次结构中所有 Link 组件的 rel 属性。

内联控制流

由于 Plot 专注于静态站点生成,因此它还附带了几个控制流机制,使您在使用其基于 Node 或基于 Component 的 API 时可以内联逻辑。例如,使用 .if() 命令,您可以仅在给定条件为 true 时选择性地添加一个节点,并且在组件的 body 中,您可以简单地内联一个常规 if 语句来执行相同的操作

let rating: Rating = ...

// When using the Node-based API:
let html = HTML(.body(
    .if(rating.hasEnoughVotes,
        .span("Average score: \(rating.averageScore)")
    )
))

// When using the Component API:
let html = HTML {
    if rating.hasEnoughVotes {
        Span("Average score: \(rating.averageScore)")
    }
}

您还可以将 else 子句附加到基于节点的 .if() 命令,该子句将充当命令条件为 false 时显示的后备节点。在使用组件 API 时,您也可以使用标准的 else 子句

// When using the Node-based API:
let html = HTML(.body(
    .if(rating.hasEnoughVotes,
        .span("Average score: \(rating.averageScore)"),
        else: .span("Not enough votes yet.")
    )
))

// When using the Component API:
let html = HTML {
    if rating.hasEnoughVotes {
        Span("Average score: \(rating.averageScore)")
    } else {
        Span("Not enough votes yet.")
    }
}

还可以使用基于 Node.unwrap() 命令内联解包可选值,该命令采用要解包的可选值和一个用于将其值转换为节点的闭包。使用基于 Component 的 API 时,您可以简单地使用标准的 if let 表达式来执行相同的操作。

以下是如何使用这些功能仅在用户登录时有条件地显示 HTML 页面的一部分。

let user: User? = loadUser()

// When using the Node-based API:
let html = HTML(.body(
    .unwrap(user) {
        .p("Hello, \($0.name)")
    }
))

// When using the Component-based API:
let html = HTML {
    if let user = user {
        Paragraph("Hello, \(user.name)")
    }
}

.if() 一样,.unwrap() 命令也可以传递一个 else 子句,如果解包的可选值结果为 nil,则将使用该子句(并且在使用基于 Component 的 API 时,可以再次使用标准的 else 子句实现等效的逻辑)

let user: User? = loadUser()

// When using the Node-based API:
let html = HTML(.body(
    .unwrap(user, {
        .p("Hello, \($0.name)")
    },
    else: .text("Please log in")
    )
))

// When using the Component-based API:
let html = HTML {
    if let user = user {
        Paragraph("Hello, \(user.name)")
    } else {
        Text("Please log in")
    }
}

最后,可以使用 .forEach() 命令将任何 Swift Sequence 转换为一组节点,这在构造基于 Node 的列表时非常有用。在构建基于 Component 的列表时,您可以直接将您的序列传递给内置的 List 组件,或者使用 for 循环

let names: [String] = ...

// When using the Node-based API:
let html = HTML(.body(
    .h2("People"),
    .ul(.forEach(names) {
        .li(.class("name"), .text($0))
    })
))

// When using the Component-based API:
let html = HTML {
    H2("People")

    // Passing our array directly to List:
    List(names) { name in
        ListItem(name).class("name")
    }

    // Using a manual for loop within a List closure:
    List {
        for name in names {
            ListItem(name).class("name")
        }
    }
}

使用上述控制流机制,尤其是与定义自定义组件的方法相结合时,您可以构建真正灵活的模板、文档和 HTML 页面 — 所有这些都以完全类型安全的方式进行。

自定义元素和属性

虽然 Plot 旨在尽可能多地覆盖与其支持的文档格式相关的标准(有关更多信息,请参见 “与标准的兼容性”),但您最终可能会遇到 Plot 尚未涵盖的某种形式的元素或属性。

值得庆幸的是,Plot 还可以轻松地定义自定义元素和属性 — 这既在构建更自由形式的 XML 文档时很有用,也可以作为 Plot 尚不支持标准的给定部分时的“紧急出口”

// When using the Node-based API:
let html = HTML(.body(
    .element(named: "custom", text: "Hello..."),
    .p(
        .attribute(named: "custom", value: "...world!")
    )
))

// When using the Component-based API:
let html = HTML {
    Element(name: "custom") {
        Text("Hello...")
    }

    Paragraph().attribute(
        named: "custom",
        value: "...world!"
    )
}

虽然上面的 API 非常适合构建一次性的自定义元素,或者暂时解决 Plot 内置功能的限制,但在大多数情况下,建议要么

extension XML {
    enum ProductContext {}
}

extension Node where Context == XML.DocumentContext {
    static func product(_ nodes: Node<XML.ProductContext>...) -> Self {
        .element(named: "product", nodes: nodes)
    }
}

extension Node where Context == XML.ProductContext {
    static func name(_ name: String) -> Self {
        .element(named: "name", text: name)
    }

    static func isAvailable(_ bool: Bool) -> Self {
        .attribute(named: "available", value: String(bool))
    }
}

起初,上面的内容可能看起来像是不必要的忙碌,但就像 Plot 本身一样,它确实可以提高您的自定义文档在未来发展中的稳定性和可预测性。

渲染文档

使用 Plot 的 DSL 完成文档的构建后,调用 render 方法将其渲染为 String,可以选择使用制表符或空格缩进

let html = HTML(...)

let nonIndentedString = html.render()
let spacesIndentedString = html.render(indentedBy: .spaces(4))
let tabsIndentedString = html.render(indentedBy: .tabs(1))

也可以独立渲染各个节点,这使得可以使用 Plot 仅构造较大文档的单个部分

let header = Node.header(
    .h1("Title"),
    .span("Description")
)

let string = header.render()

与节点一样,组件也可以单独渲染

let header = Header {
    H1("Title")
    Span("Description")
}

let string = header.render()

Plot 的构建考虑到了性能,因此无论您如何渲染文档,目标都是使该渲染过程尽可能快 — 具有非常有限的节点树遍历以及尽可能少的字符串复制和插值。

RSS feed、播客和站点地图

除了 HTML 和自由格式的 XML 之外,Plot 还附带 DSL API,用于构建 RSS 和 Podcast Feed,以及用于搜索引擎索引的 SiteMap XML。

虽然这些 API 最有可能只在构建工具和自定义生成器时才相关(即将推出的静态站点生成器 Publish 包含了所有这些格式的实现),但它们提供的类型安全性与使用 Plot 构建 HTML 页面时相同。

let rss = RSS(
    .item(
        .guid("https://mysite.com/post", .isPermaLink(true)),
        .title("My post"),
        .link("https://mysite.com/post")
    )
)

let podcastFeed = PodcastFeed(
    .title("My podcast"),
    .owner(
        .name("John Appleseed"),
        .email("john.appleseed@url.com")
    ),
    .item(
        .title("My first episode"),
        .audio(
            url: "https://mycdn.com/episode.mp3",
            byteSize: 79295410,
            title: "My first episode"
        )
    )
)

let siteMap = SiteMap(
    .url(
        .loc("https://mysite.com/post"),
        .lastmod(Date()),
        .changefreq(.daily),
        .priority(1)
    )
)

有关构建 Podcast Feed 所需数据的更多信息,请参阅 Apple 的播客指南,有关 SiteMap 格式的更多信息,请参阅 其官方规范

系统要求

为了能够成功使用 Plot,请确保您的系统已安装 Swift 5.4(或更高版本)。如果您使用的是 Mac,还要确保 xcode-select 指向包含所需 Swift 版本的 Xcode 安装,并且您运行的是 macOS Big Sur (11.0) 或更高版本。

请注意,Plot **不**正式支持任何形式的 Beta 软件,包括 Xcode 和 macOS 的 Beta 版本,或未发布的 Swift 版本。

安装

Plot 使用 Swift Package Manager 进行分发。 要将其安装到项目中,只需在您的 Package.swift 清单中将其添加为依赖项即可。

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/johnsundell/plot.git", from: "0.9.0")
    ],
    ...
)

然后在您想要使用它的地方导入 Plot

import Plot

有关如何使用 Swift Package Manager 的更多信息,请查看 这篇文章,或 其官方文档

浏览 Plot 的 API 和实现

Plot 由四个核心部分组成,它们共同构成了它的 DSL 和整体文档渲染 API

Plot 大量使用了一种称为 *幻影类型* 的技术,该技术在类型用作编译器的“标记”,以便通过 泛型约束 强制类型安全时使用。 DocumentFormat 和节点、元素或属性的 Context 都以这种方式使用 - 因为这些类型永远不会被实例化,而只是将它们的值与给定的上下文或格式关联。

Plot 还使用了一种非常 轻量级的 API 设计,最大限度地减少了外部参数标签,以减少渲染文档所需的语法数量 - 使其 API 具有非常“类似 DSL”的设计。

Component API 使用 Result BuildersProperty Wrappers 语言特性,使其非常类似于 SwiftUI 的 API 生动起来。

与标准的兼容性

Plot 的最终目标是与支持它的文档格式的所有标准完全兼容。 但是,作为一个非常年轻的项目,它很可能需要社区的帮助才能使其更接近该目标。

以下标准旨在由 Plot 的 DSL 覆盖

请注意,Component API 目前仅涵盖 HTML 5.0 规范的子集,并且目前仅可用于定义 HTML 页面的 <body> 中的元素。

如果您发现缺少某个元素或属性,请添加它并提交包含该添加的 Pull Request。

鸣谢、替代方案和重点

Plot 最初由 John Sundell 作为静态站点生成工具 Publish 套件的一部分编写,该套件用于构建和生成 Swift by Sundell。 该套件还包括 Markdown 解析器 Ink,以及 Publish 本身。

使用 Swift 生成 HTML 的想法也已被社区中的许多其他人和项目探索过,其中一些类似于 Plot,另一些则完全不同。 例如 Leaf by Vapor, swift-html by Point-Free, 以及 Swift Talk backend by objc.io。 在这个领域内进行大量同步创新是一件好事 - 因为所有这些工具(包括 Plot)都围绕其整体 API 设计和范围做出了不同的决策,这让每个开发人员都可以选择最适合他们个人品味和需求的工具(或者可能构建另一个工具?)。

Plot 的主要重点是基于 Swift 的静态站点生成,以及支持构建网站时使用的各种格式,包括 RSS 和 Podcast Feed。 它还与 Publish 静态站点生成器紧密集成,并且旨在使 Publish 尽可能快速和灵活,而无需承担任何第三方依赖项。 它作为一个单独的项目开源,既是从架构的角度来看,也是为了使其他工具能够在它之上构建,而无需依赖于 Publish。

贡献和支持

Plot 完全公开开发,非常欢迎您的贡献。

在开始在任何项目中使用 Plot 之前,强烈建议您花几分钟时间熟悉其文档和内部实现,以便您准备好解决可能遇到的任何问题或边缘情况。

由于这仍然是一个年轻的项目,因此很可能存在许多限制和缺失的功能,只有当更多人开始使用它时,才能真正发现和解决这些问题。 虽然 Plot 在生产中用于构建和渲染 Swift by Sundell 的所有内容,但建议您首先针对您的特定用例进行尝试,以确保它支持您需要的功能。

本项目不提供基于 GitHub Issues 的支持或任何其他类型的直接支持渠道,而是鼓励用户积极参与其持续开发 - 通过修复他们遇到的任何错误,或者通过改进发现不足的文档。

如果您希望进行更改,请打开一个 Pull Request - 即使它只包含您计划进行的更改的草稿,或重现问题的测试 - 我们可以在那里进一步讨论。 有关如何为此项目做出贡献的更多信息,请参阅Plot 的贡献指南

希望您喜欢使用 Plot!