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 还附带了用于构建 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 的 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 Vaporswift-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!