这个网站以前是用 Astro 做的。现在全部是 Swift:每一个页面、组件和样式。和我的 app 用同一种语言,感觉很像 SwiftUI。
浏览器拿到的仍然是普通的 HTML 和 CSS,外加一点 JavaScript。改变的是它的构建方式,以及我做它时有多享受。
为什么用 Swift?
我整天都在写 Swift,而且我很喜欢它。从 app 转到网站时,我不再需要切换语言和工具。
但真正的原因是编译器。一个网站由许多需要彼此一致的小东西组成:链接、类名、样式、脚本,以及 60 多个 app 的数据。在典型的 Web 技术栈里,它们只在浏览器里相遇,一个错误就会变成一个坏掉的页面。而在这里,它们大多在编译器里相遇。重命名一个页面,代码里所有指向它的链接都会停止编译。
它看起来像 SwiftUI
一个组件是一个 struct,带有一个body:
swift
struct BackLink: HTML {
let app: App
var body: some HTML {
a(.href(app.path)) {
"Back to\(app.title)"
}
.style {
$0
.secondaryText()
.fontWeight(.medium)
.hoverColor(.primaryText)
}
}
}
HTML 来自Elementary。其余部分(样式、路由和内容)是我自己在上面搭的小框架。
你在这里看到的语法高亮也是 Swift。构建过程让代码经过 Swift 自己的解析器,swift-syntax,并为每个 token 着色,所以颜色是对的,而且不需要为此写 JavaScript。
样式就是修饰符
样式加在元素上,就像 SwiftUI 里的修饰符。上面的.style { … }会变成一个类似这样的类back-link-1x2y3z4,来自文件名和样式的哈希。它不会泄漏到其他元素,而且当我检查 HTML 时,我知道它来自哪个文件。
共享的外观和变体是 enum case,所以它们能自动补全,打错字就无法编译。下面是 badge 组件的简短版本:
swift
struct Badge: HTML {
enum Kind: StyleSet {
case standard
case new
var style: Style {
switch self {
case .standard:
Style().background(.gray(200), dark: .gray(800))
case .new:
Style().background(.teal(100), dark: .teal(900))
}
}
}
enum Styles: StyleSet {
case root
var style: Style {
Style()
.display(.inlineBlock)
.padding(.horizontal, .rootEm(0.375))
.cornerRadius(.rootEm(0.5))
.bold()
}
}
let title: String
var kind = Kind.standard
var body: some HTML {
span {
title
}
.style(Styles.root, kind)
}
}
使用它就像使用一个 SwiftUI 视图:
swift
Badge(title: "New", kind: .new)
每个页面只会得到它所展示组件的 CSS。在底层,它是现代 CSS:级联层、light-dark()、相对颜色、滚动驱动动画和容器单位。没有预处理器,也没有 Tailwind。
带类型的 CSS 变量
CSS 自定义属性只是字符串。而在这里,它们有类型:
swift
static let color = StyleVariable<Color>("--overflow-menu-color")
// 菜单使用它,并带有一个默认值。
.color(OverflowMenu.color.value(default: .link))
// 父级设置它。
.setting(OverflowMenu.color, to: .bodyText)
把它设为一个长度无法通过编译。有了RegisteredProperty,页面还会获得@property规则,因此浏览器可以对其做动画,就像首页上的渐变那样:
swift
static let gradientStart = RegisteredProperty<Color>("--gradient-start", syntax: "<color>", initialValue: .black)
响应式且无障碍
断点和媒体查询都是带类型的,媒体查询可与&&组合:
swift
Style()
.grid(columns: 2)
.from(.tablet) {
$0.gridColumns(3)
}
.media(.coarsePointer) {
$0.padding(.rootEm(0.75))
}
.media(.hover && .finePointer && .allowsMotion) {
$0.hover {
$0.setting(titleLight, to: .percent(35))
}
}
最后一个是跟随指针在 apps 页面标题上移动的光效。它只在有鼠标或触控板时出现,并且只在允许动效时出现。
颜色是语义化的,比如.secondaryText。每个都有浅色和深色值,而当你在系统设置中要求更高对比度时,文本、链接和线条的颜色会获得更强的值。组件免费获得这一切。
链接
链接的工作方式与 SwiftUI 中一样。来自 404 页面:
swift
Link("Back to Homepage", destination: .path(.root))
.buttonStyle(.primary)
Link("Browse Apps", destination: .path(.apps))
.buttonStyle(.secondary)
页面是静态成员,所以.apps会自动补全,而不存在的页面无法通过编译。构建之后,生成的 HTML 中的每个链接和 ID 也会被检查。
脚本
带有行为的组件是一个自定义元素。Swift 渲染 HTML,而脚本就是该元素的类。小脚本直接放在 Swift 文件里,较大的脚本放在旁边的.js文件中。脚本只触及自己的元素,因此它不会破坏页面上的其他东西。没有框架,也没有打包器。
小巧而快速
一篇博客文章压缩后约 14 KB:6.6 KB 的 HTML、5.4 KB 的 CSS 和 1.8 KB 的 JavaScript。一个应用页面,比如 Lungo 的那个,在截图之前大约 29 KB。除了一份共享的小型样式表,页面只加载自己组件的样式和脚本,没有 Web 字体,首屏以下的图片懒加载。在 Chrome 中,把指针停在链接上就会在后台加载页面,所以它常常瞬间打开。
内容即数据
每个应用都是一个带有类型化元数据的 Markdown 文件:
swift
@Frontmatter
struct Frontmatter {
@NonEmpty var title: String
@NonEmpty var subtitle: String
@CalendarDay var publicationDate: Date
var platforms: [Platform]
var isPaid = false
}
@Frontmatter是一个 Swift 宏,在编译时生成解码器。@NonEmpty拒绝空值,而@CalendarDay把'2017-07-26'变成一个Date并拒绝像'2026-02-30'这样的日期。一个错误,比如未知的平台或拼错的键,会作为带有文件和行号的错误报出,有时还会附上“Did you mean”建议。
构建过程还会从 App Store 获取每个应用的评分、价格和评论。swift run website check能捕捉编译器捕捉不到的问题,比如拼写错误,或者某个页面说应用需要 macOS 14,而 App Store 说需要 macOS 15。
愉悦
警告会让构建失败,还有几百个测试检查类型检查不了的东西,比如每个 class 都有一条 CSS 规则。所以我可以放心地做大改动。一切都有自动补全,重构就是重命名。代码读起来像 SwiftUI,没有 class 大杂烩,也没有需要记在脑子里的选择器。一个组件把它的标记、样式和脚本放在一处,当我删除它时,它的 CSS 和 JavaScript 也随之消失。而且swift run website serve在我保存时重新加载页面。
现在做这个网站和做那些应用一样有趣。
The源代码是开源的。
还有一件事
对这一切最好的压力测试是我 1999 年的主页:那个网站是当年的个人主页,有留言簿、一个外星人游戏、一只虚拟独角兽和一个 MIDI 点唱机。它有 81 个自定义元素,用的组件和样式与网站其余部分相同。去留言簿签个名吧。