Mar 21, 2022

|

为 Publish 打造一款全新的主题

最近我将博客从 Hexo 迁移到了 Publish,整个过程十分顺利,以及 Publish 本身可玩度很高,唯一美中不足的是生态系统的成长度相对较低,主题和插件的数量并不多,不过也正因如此,懒惰的我才选择了从零开始实现一款简单易扩展的主题。本文我将会围绕如何开发一款 Publish 主题进行简单讨论和 tips 分享。

关于 Publish

Publish 是一款由 John Sundell 开发的纯 Swift 实现的静态页面生成框架。如果你熟悉 Node.js 驱动的 Hexo,或是 Go 实现的 Hugo 等框架,应该会非常欣喜于 Publish 为我们带来了用 Swift 搭建一个静态站点的可能性。

我们都知道构建网页并不是 Swift 擅长的领域,实现一个这样的框架绝非一件易事,至少需要考虑如何生成 HTML,如何解析 Markdown 等等。事实上,Publish 本身更像是一个骨架,它依赖于其他几个功能强大的库,例如生成 HTML 和 RSS 是 Plot 的工作,而 Markdown 的解析则全靠 Ink,其余开源库也都是非常有用的轮子。你可以在 Package.swift 中找到 Publish 的所有的依赖:

// Package.swift
dependencies: [
    "Ink", "Plot", "Files", "Codextended", "ShellOut", "Sweep"
]

从工程角度来看,Publish 同样是极其出色的,它由可复用的组件构成而非不可拆分的一个整体,有充足的测试,也有完备的注释,瞬间就可以生成 DocC。这一切完全是理想中的框架该有的形态。

快速上手 Publish

如果你已经拥有一个由 Publish 生成的站点,可以直接跳过这一章节。即便没有也完全不用担心,差的仅仅只是三五行命令,第一步,我们需要安装 Publish 的命令行工具,最简单的方法如下:

$ git clone https://github.com/JohnSundell/Publish.git
$ cd Publish
$ make

如果你更倾向于 Homebrew,也可以通过 brew install publish 来完成上述步骤。命令行工具默认提供了四个可用命令:

- new: Set up a new website in the current folder.
- generate: Generate the website in the current folder.
- run: Generate and run a localhost server on default port 8000
       for the website in the current folder. Use the "-p"
       or "--port" option for customizing the default port.
- deploy: Generate and deploy the website in the current
       folder, according to its deployment method.

第二步,在指定文件夹下执行 publish new 来完成剩下的配置。

$ mkdir MyWebsite
$ cd MyWebsite
$ publish new

准备就绪,接着执行 publish run,随后就可以在 http://localhost:8000 看到网站的初貌啦!

自定义主题

读到这里你或许会感到疑惑,接下来是要开始讲述如何写 CSS 了吗?尽管以前曾短暂与 Frontend 为伴,然而时过境迁,此刻的我是如此想拥有一本《面向 iOS 开发者的 CSS 指南》。

在接下来的章节中,CSS 是一个无法跳过的话题,除此之外,我还想分享一些个人在实践中的思考和收获。下文无数次提到的 PaletteTheme 在这里~: https://github.com/Ckitakishi/PaletteTheme

用 Swift Package 管理主题

在实际开始开发之前,首先需要考虑的是用何种方式管理主题,关注点在于今后的扩展性,版本迭代和可复用与否。

这样一来,直接基于上述的 MyWebsite 进行下一步开发就不再可行。一方面,如果主题属于站点的一部分,那不容易复用和难以进行版本管理就是它与生俱来的特点。另一方面,不太恰当的开端有一定几率会诱致更多不合理的实现,以下述代码为例,section 是各个站点自由定义的内容,因此主题并不该知道,在主题代码中为具体的站点元素提供服务是一种本末倒置:

// 主题模版
func makeSectionHTML(for section: Section<Site>,
                        context: PublishingContext<Site>) throws -> HTML {
    HTML(
        .lang(context.site.language),
        .head(for: section, on: context.site),
        .body {
            SiteHeader(context: context, selectedSelectionID: section.id)
            if section.id == xxx { 
                // 特定 section 的式样
            }
            ...
        }
    )
}

换个角度来说,将主题作为一个库进行管理会是一个更加合理的方向。而站点本身是一个 Swift Package,将主题也作为一个 Swift Package 来开发可是说是不二之选。

首先,让我们来创建一个新 Package (Xcode > File > New > Package),并在 Package.swiftdependencies 中追加 Publish。

let package = Package(
    name: "PaletteTheme",
    ...
    dependencies: [
        .package(
            name: "Publish",
            url: "https://github.com/johnsundell/publish.git",
            from: "0.7.0"
        ),
    ],
    ...
)

然后将刚创建的主题包作为本地依赖添加到 MyWebsite 中,这么做的理由是为了即时预览和调试主题。就像下面这样:

let package = Package(
    name: "MyWebsite",
    ...
    dependencies: [
        .package(name: "Publish", url: "https://github.com/johnsundell/publish.git", from: "0.7.0"),
        .package(path: "../PaletteTheme"), // 本地依赖
    ],
    targets: [
        .target(
            name: "MyWebsite",
            dependencies: ["Publish", "PaletteTheme"]
        )
    ]
)

构建主题

一切准备就绪,接下来就可以着手于主题的构建了。本着知其然也要知其所以然的精神,首先需要了解 Website 协议,它定义了一些站点 URL 和名字之类的基础属性,以及构建整个站点的 pipeline 等等,任何 Publish 的站点都需要遵循该协议,在 Website.swift 中可以找到更多细节。

public protocol Website {
    /// The enum type used to represent the website's section IDs.
    associatedtype SectionID: WebsiteSectionID
    /// The type that defines any custom metadata for the website.
    associatedtype ItemMetadata: WebsiteItemMetadata

    /// The absolute URL that the website will be hosted at.
    var url: URL { get }
    /// The name of the website.
    var name: String { get }
    ...
}

Website 非常基础,为了在此基础上添加更多属性和功能,显而易见我们需要扩展 Website。这里我选择的方案是定义一个类型别名 PaletteWebsite,用它来表示同时遵循 Website 和主题自定义协议 PaletteCustomizable 的类型。当然了,条条大路通罗马,比如说定义一个同时继承于 WebsitePaletteCustomizable 的新协议也是完全可行的。

// 类型别名
public typealias PaletteWebsite = Website & PaletteCustomizable

// 协议
public protocol PaletteWebsite: Website, PaletteCustomizable { }

你大概已经猜到,PaletteCustomizable 正是定义了主题扩充属性的协议。如果 Website 被称为基础模块,那我们可以将 PaletteCustomizable 视为扩展模组,也就是说,如果你喜欢,可以定义无数个扩展模组来让事情变得更有趣(和复杂)。

public protocol PaletteCustomizable {
    /// The self-introduction that will be displayed on the home page. Support Markdown syntax.
    var aboutMe: String { get }
    /// The `PalettePage`s that the website will include.
    var pages: [PalettePage] { get }
    /// The path to profile icon.
    var profileIconPath: URLRepresentable? { get }
    ...
}

/// Default implementation
public extension PaletteCustomizable {
    var profileIconPath: URLRepresentable? { nil }
    ...
}

到这里 PaletteWebsite 的大框架就差不多完成了,进展很令人开心,下一步则是要将它运用到主题上。再度回到 Publish 源码,其中有一个名为 Theme 的泛型结构体定义了 Publish 主题,这里需要留意的是初始化参数 htmlFactory,它的类型是一个同样支持泛型的协议 HTMLFactory:

// Theme
public struct Theme<Site: Website> {
    public init<T: HTMLFactory>(
        htmlFactory factory: T,
        resourcePaths resources: Set<Path> = [],
        file: StaticString = #file
    ) where T.Site == Site {
        ...
    }
}

// HTMLFactory
public protocol HTMLFactory {
    associatedtype Site: Website
    ...
}

顾名思义,HTMLFactory 和 HTML 的生成息息相关,它定义了六个必须实现的方法,分别用于构建六种类型的页面,例如主页,section 页面,tag 一览画面等。接下来,我们需要依次实现这六个工厂方法,在 PaletteTheme 中我定义了一个名为 PaletteThemeHTMLFactory 的结构体来完成这件事。

在上文中曾经提到过我们将会使用 Plot 来生成 HTML 代码,和 HTML 一样,Plot 也是一个 DSL (领域特定语言),毕竟 Plot 算是为 HTML/XML 而生,它们的写法可是说是如出一辙。如果你对 HTML 有些许了解,那看到下述 Plot 代码一定会很自然地联想到它对应的 HTML。

struct PaletteThemeHTMLFactory<Site: PaletteWebsite>: HTMLFactory {
    // 实现所有六个方法
    ...
    func makePageHTML(for page: Page,
                      context: PublishingContext<Site>) throws -> HTML {
        HTML(
            .lang(context.site.language),
            .head(for: page, on: context.site),
            .body {
                SiteHeader(context: context, selectedSelectionID: nil)
                Wrapper(page.body)
                SiteFooter()
            }
        )
    }
    ...
}

尽管看起来相似,但是 Plot 在运用了 Swift 特点的基础上还提供了更灵活的玩法,例如类型安全,流程控制和组件化。其中最重要的非组件化莫属,提到组件化就必须知道两个概念,NodeComponent。在版本 0.9.0 之前,Plot 只提供了基于 Node 的方式来构建组件,Node 可以代表元素和属性,上一段示例代码中的 .lang.head.body 就是三个 Node。而 Component 则是比 Node 更高层次的 API,例如上述的 SiteHeader, 根据文档推测,这个 API 的诞生是为了提供更接近于 SwiftUI 的写法,需要留意的一点是,Component 目前只可以被用于定义出现在 body 之内的元素,其他 HTML 元素和非 HTML 元素需要继续使用 Node 来生成。

Node 较于 Component 在性能方面有细微的优势,但从偏好来说我个人更喜欢后者的写法。不过无论哪种都可以帮助我们达成目的,以及,我们可以非常自由地混用二者:

// 将 Component 集成到基于 Node 的层次结构中
func newsArticlePage(for article: NewsComponent) -> HTML {
    return HTML(.body(
        .div(
            .component(article)
        )
    ))
}

// 在 Component 内部使用基于 Node 的元素
struct Banner: Component {
    var body: Component {
        Div {
            Node.h2(.text("title"))
        }
        .class("banner")
    }
}

关于 Plot,以上内容只不过是皮毛,文档中列举了更丰富的功能和示例:https://github.com/JohnSundell/Plot

最后,将遵循 HTMLFactory 协议的 PaletteThemeHTMLFactory 运用到主题上即可。

// Theme extension
public extension Theme where Site: PaletteWebsite {
    static var palette: Self {
        Theme(
            htmlFactory: PaletteThemeHTMLFactory(),
            resourcePaths: ["Resources/styles.css"]
        )
    }
}

CSS 怎么写@@

相信此刻你应该已经拥有了一个稍显乏味的 HTML 站点,正准备开始改造页面的布局和样式。

当我还在日常与前端为伴时就觉得 CSS 完全是看似简单,实践起来不仅坑很多且有时难以顾周全,仅仅是处理多浏览器兼容这一项就足够辛苦。基于这个原因,这次我基本放弃了自己从零开始写 CSS 的想法,转而寻找一个简单易上手,能够提供多浏览器支持,响应式设计,flex 和 grid 布局等的框架。

在我的认知中,有很多框架的核心理念是提供组件,例如 Bootstrap,用户可以简单的将组件拼在一起组成一个完整的页面。在我的计划中,对特别复杂的组件并不是很有需求,反而更倾向于细粒度的框架,因此最终选择了一个以 Utility-First 为理念的框架 -- Tailwind CSS。

你可以在 Tailwind CSS 的文档 中找到导入方法。这里要注意的是,Tailwind CSS 会通过扫描文件来搜寻正在使用的 class 以生成目标 CSS 文件,这个过程不仅对 HTML 和 JavaScript 有效,即便是 Swift 也完全没有问题,务必要正确配置 tailwind.config.js 文件:

module.exports = {
  content: ['../Sources/**/*.swift'],
  ...
}

此外,Tailwind CSS 官方提供了一个名为 typography 的插件,用于为无法控制的 HTML 添加默认式样,例如 Markdown。其配置方法如下:

module.exports = {
  ...
  plugins: [
    require('@tailwindcss/typography'),
  ],
}

如果想要修改默认的 typography 样式,可以参照 styles.js 文件并覆盖对应属性,例如将 h1 标签的默认字号改为 1.875rem

module.exports = {
  ...
  theme: {
    extend: {
      typography: {
        DEFAULT: {
          css: {
            h1: {
              fontSize: '1.875rem',
            },
          },
        },
      },
    },
  },
}

代码高亮

Tailwind CSS 的排版插件为 Markdown 生成的 HTML 提供了默认式样,不过其中并不包括代码语法高亮。Publish 列举了三种高亮代码的工具,分别是 Splash,Pygments 和 highlight.js。它们的简介和详细安装方法都可以在 Publish 的 HowTo 文档中找到。

最终我选择了 Splash 为 PaletteTheme 的代码块加高亮,这是一个优秀的 Swift 原生代码高亮框架,同样出自 John Sundel 之手,说不上是缺憾,不过它只支持 Swift 代码,如果你是一名多栖开发者,那这未必是一个好的选择。Pygments 和 highlight.js 都拥有更全面的语言支持,但是对 Swift 的支持相对更弱一些。

以 Splash 为例,为了灵活性,框架本身并不包含任何颜色定义,因此还需要在 CSS 文件中追加自定义的颜色代码,如下所示:

// Monokai color scheme 
pre code .keyword {
    color: #f92672;
}

pre code .type {
    color: #a6e22e;
}

尾声

至此,一个全新的主题就大功告成啦,这是漫漫长路上的第一步,今后可以继续为它创造更丰富的功能和插件。目前 Publish 社区比较沉寂,很多人或许还对 Publish 是否是一款可用的博客生成框架持有怀态度,希望通过这篇文章,带给了你切实的帮助亦或是一个崭新的视角。