这个网站以前是用 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 个自定义元素,用的组件和样式与网站其余部分相同。去留言簿签个名吧。