欢迎使用 Plot,这是一种特定领域语言 (DSL),用于在 Swift 中编写类型安全的 HTML、XML 和 RSS。它可以用作构建网站、文档和 Feed 的工具,也可以作为模板工具或更高级别组件和工具的渲染器。它的主要重点是静态站点生成和基于 Swift 的 Web 开发。
Plot 用于构建和渲染 swiftbysundell.com 的所有内容。
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 文档时,可以混合使用基于 Node
和 Component
的元素,从而可以灵活地自由选择哪种方式来实现网站或文档的哪个部分。
例如,假设我们正在使用 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")
}
}
如上例所示,修饰符也可以应用于组件以设置属性的值,例如 class
或 id
。
然后,要将上述组件集成到基于 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 通过层次结构向下传递值。 一旦使用 EnvironmentKey
和 environmentValue
修饰符将值输入到环境中,就可以通过在 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 内置功能的限制,但在(大多数情况下)建议改为:
Node
类型,以与 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 的构建考虑了性能,因此无论您如何渲染文档,目标都是使渲染过程尽可能快 - 具有非常有限的节点树遍历以及尽可能少的字符串复制和插值。
除了 HTML 和自由形式的 XML 之外,Plot 还附带了用于构建 RSS 和播客 Feed 以及用于搜索引擎索引的 SiteMap XML 的 DSL API。
虽然这些 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)
)
)
有关构建播客 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 由四个核心部分组成,它们共同构成了其 DSL 及其整体文档渲染 API
Node
是任何 Plot 文档中所有元素和属性的核心构建块。它可以表示元素和属性,以及文本内容和节点组。每个节点都绑定到 Context
类型,该类型确定它有权访问哪种 DSL API(例如,对于放置在 HTML 页面 <body>
中的节点,为 HTML.BodyContext
)。Element
表示一个元素,可以使用两个单独的标签(如 <body></body>
)打开和关闭,也可以自闭合(如 <img/>
)。通常在使用 Plot 时不必与此类型交互,因为您可以通过其 DSL 创建它的实例。Attribute
表示附加到元素的属性,例如 <a>
元素的 href
或 <img>
元素的 src
。您可以通过其初始化器或通过 DSL 使用 .attribute()
命令来构造 Attribute
值。Component
协议用于以非常类似 SwiftUI 的方式定义组件。每个组件都需要实现一个 body
属性,可以在其中使用其他组件或基于 Node
的元素构造其渲染输出。Document
和 DocumentFormat
表示给定格式的文档,例如 HTML
、RSS
和 PodcastFeed
。这些是顶级类型,您可以使用它们来启动使用 Plot 的 DSL 的文档构建会话。Plot 大量使用一种称为幻影类型的技术,即类型用作编译器的“标记”,以便能够通过 泛型约束强制执行类型安全。DocumentFormat
以及节点、元素或属性的 Context
都以这种方式使用 - 因为这些类型永远不会被实例化,而只是将它们的值与给定的上下文或格式相关联。
Plot 还使用了非常 轻量级的 API 设计,最大限度地减少了外部参数标签,从而减少了渲染文档所需的语法量,从而使其 API 具有非常“类似 DSL”的设计。
Component
API 使用 Result Builders 和 Property 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 和播客 feed。它还与 Publish 静态站点生成器紧密集成,旨在使 Publish 尽可能快速和灵活,而无需承担任何第三方依赖项。它被开源为一个单独的项目,既是从架构角度考虑,也是为了使其他工具能够在它之上构建,而无需依赖 Publish。
Plot 完全公开开发,我们非常欢迎您的贡献。
在开始在您的任何项目中使用 Plot 之前,强烈建议您花几分钟时间熟悉其文档和内部实现,以便您准备好解决可能遇到的任何问题或边缘情况。
由于这仍然是一个年轻的项目,它可能有很多局限性和缺失的功能,这只有随着越来越多的人开始使用它才能发现和解决。虽然 Plot 用于生产中来构建和渲染所有 Swift by Sundell,但建议您首先针对您的特定用例进行尝试,以确保它支持您需要的功能。
本项目不提供基于 GitHub Issues 的支持或任何其他类型的直接支持渠道,而是鼓励用户成为其持续开发的积极参与者——通过修复他们遇到的任何错误,或者通过改进发现不足的文档。
如果您希望进行更改,请打开一个 Pull Request——即使它只包含您计划进行的更改的草稿,或者一个重现问题的测试——我们可以在此基础上进一步讨论。有关如何为此项目做出贡献的更多信息,请参见Plot 的贡献指南。
希望您喜欢使用 Plot!