自 23 年本博客从 WordPress 换到 Halo 到现在,已经过去三年。这期间我也给 Halo 的开源社区贡献过不少代码,如 plugin-aplayer theme-lapis plugin-pangu 等,也与 Maintainer 大佬们有过交流。其中对 lapis 这款主题更是倾注了大量心血。

然而随着文稿变多、记录工具也在变化,我越来越觉得 Halo 并不是我期望中个人站点框架的样子,并在两个月前正式萌生了更换框架的念头。

现有建站框架的不足

像 Halo、WordPress 这类重型建站工具,设计目标本来就不是「个人站点」。它们更偏 B 端:安全、可扩展、功能齐全,代价则是繁重与死板。Spring 框架可以直接吃掉我 2 核 2G 轻量服务器的大半内存。而对个人使用来说,更该优先的是灵活、配置简单、轻量、易开发。

于是我去了解了 Hexo、Quartz 这类静态框架。它们从 Markdown 直接构建静态网页,无需服务端即可部署,几乎零开销;也与我现在用 Obsidian 写作的习惯契合,更利于 Agent 去管理内容。

但服务端评论对我是刚需 —— 我认为博客有评论才会热闹,陌生过客的几句留言与互动,也是难得的小幸福。外接评论服务又过于繁琐,类似 Waline 之类的方案也偏臃肿。这条路只好作罢。

另外像 Typecho 这种极为轻量且带后端的框架,也被我以 PHP 太过古早、自己也不熟悉为理由放弃。

最终只好动手写一个全新的个人站点框架。它的设计理念是:数据完全归你所有,由你掌控。思考良久,我认为它至少需要:

  • 内容以 Markdown 文件为唯一真相;
  • 自带服务端,支持服务端渲染;内容变更后可重建并原子切换发布视图;
  • 插件提供服务端能力,动态数据写入本地 SQLite,且该库可被外部读写;
  • 主题声明集合与路由,把 Markdown 内容组织成页面;
  • 需要交互时,用 island 局部增强 Preact 组件。

基于这些设定,我开发了 Diitey

GitHubAziteee/diiteyMarkdown-first personal site system — content as truth, themes define structureTypeScript10

这个名字取自《上古卷轴》系列中的龙语(Thu'um),由 D3 (Dii) 与T9 (Tey) 复合而成,意为「我的故事」(My Tale)。

它的许多设计都与常见框架不同:

主题职责

下面是一个最小 theme.ts 示例:

import {
function collection(definition: CollectionDefinition): CollectionDefinition
collection
,
function defineTheme(definition: ThemeDefinition): ThemeDefinition (+1 overload)
defineTheme
,
function page(name: string, data: PageDefinition["data"]): PageDefinition
page
,
function route(path: string, page: PageDefinition, options?: {
readonly canonical?: boolean;
}): RouteDefinition
route
} from "diitey";
import {
import z
z
} from "zod";
export default
defineTheme<{
siteName: string;
}>(definition: ConfigurableDefinition<{
siteName: string;
}, ThemeDefinition>): ConfigurableDefinition<{
siteName: string;
}, ThemeDefinition> (+1 overload)
defineTheme
({
ConfigurableDefinition<{ siteName: string; }, ThemeDefinition>.config: ValueSchema<{
siteName: string;
}>
config
:
import z
z
.
function object<{
siteName: z.ZodString;
}>(shape?: {
siteName: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
siteName: z.ZodString;
}, z.core.$strip>
object
({
siteName: z.ZodString
siteName
:
import z
z
.
function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string
().
_ZodString<$ZodStringInternals<string>>.trim(): z.ZodString
trim
().
_ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodString
min
(1) }).
ZodObject<{ siteName: ZodString; }, $strip>.strict(): z.ZodObject<{
siteName: z.ZodString;
}, z.core.$strict>

Consider z.strictObject(A.shape) instead

strict
(),
ConfigurableDefinition<{ siteName: string; }, ThemeDefinition>.setup(config: {
siteName: string;
}): ThemeDefinition
setup
(
config: {
siteName: string;
}
config
) {
return {
ThemeDefinition.styles?: string | undefined
styles
: "styles",
ThemeDefinition.collections: Readonly<Record<string, CollectionDefinition>>
collections
: {
articles: CollectionDefinition
articles
:
function collection(definition: CollectionDefinition): CollectionDefinition
collection
({
CollectionDefinition.from: string
from
: "articles/*/*.md",
CollectionDefinition.where?: Readonly<Record<string, WhereCondition>> | undefined
where
: {
draft: {
not: boolean;
}
draft
: {
not: unknown
not
: true } },
CollectionDefinition.orderBy?: readonly {
readonly field: string;
readonly direction: "asc" | "desc";
}[] | undefined
orderBy
: [{
field: string
field
: "created",
direction: "desc" | "asc"
direction
: "desc" }],
CollectionDefinition.schema: Readonly<Record<string, SchemaType>>
schema
: {
title: "string"
title
: "string",
tags: "string[]?"
tags
: "string[]?",
draft: "boolean?"
draft
: "boolean?",
},
}),
},
ThemeDefinition.routes: readonly RouteDefinition[]
routes
: [
function route(path: string, page: PageDefinition, options?: {
readonly canonical?: boolean;
}): RouteDefinition
route
(
"/writing/:year/:slug",
function page(name: string, data: PageDefinition["data"]): PageDefinition
page
("article", {
item: {
collection: string;
match: string;
}
item
: {
collection: string
collection
: "articles",
ItemBinding.match: string
match
: "articles/:year/:slug.md",
},
}),
),
],
};
},
});

可以看到,主题的主要职责是:定义集合、声明路由,并把内容注入对应的 tsx 页面组件,再由组件渲染出来。主题的职责从「静态 HTML 模板」变为服务端的一部分,参与 SSR 的过程中,因而更灵活。

插件职责

主题职责变宽之后,插件的边界变得更为清晰。在 Diitey 中,插件主要做两件事:

提供服务

插件可以声明类型化服务,在主题 SSR 时被调用,或经受控 Action 暴露给浏览器。例如评论插件注册读取、发布接口,并将数据存在 SQLite 中。

这样一来,插件与主题的关系很接近「后端与前端」:插件提供能力,主题只决定如何展示 —— 可以在文章页直接渲染评论,也可以通过 island 挂载可交互的评论组件。

下面是一个 Todo-list 插件示例:

import {
function definePlugin(definition: PluginDefinition): PluginDefinition (+1 overload)
definePlugin
,
class PluginNotFoundError
PluginNotFoundError
} from "diitey";
import {
import z
z
} from "zod";
export default
definePlugin<unknown>(definition: ConfigurableDefinition<unknown, PluginDefinition>): ConfigurableDefinition<unknown, PluginDefinition> (+1 overload)
definePlugin
({
ConfigurableDefinition<unknown, PluginDefinition>.config: ValueSchema<unknown>
config
:
any
todoListConfig
,
ConfigurableDefinition<unknown, PluginDefinition>.setup(config: unknown): PluginDefinition
setup
(
config: unknown
config
) {
const
const createInput: z.ZodObject<{
title: z.ZodString;
}, z.core.$strict>
createInput
=
import z
z
.
function object<{
title: z.ZodString;
}>(shape?: {
title: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>
object
({
title: z.ZodString
title
:
import z
z
.
function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string
().
_ZodString<$ZodStringInternals<string>>.trim(): z.ZodString
trim
().
_ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodString
min
(1).
_ZodString<$ZodStringInternals<string>>.max(maxLength: number, params?: string | z.core.$ZodCheckMaxLengthParams): z.ZodString
max
(
config: unknown
config
.
any
maxTitleLength
) }).
ZodObject<{ title: ZodString; }, $strip>.strict(): z.ZodObject<{
title: z.ZodString;
}, z.core.$strict>

Consider z.strictObject(A.shape) instead

strict
();
return {
PluginDefinition.id?: string | undefined
id
: "todo-list",
PluginDefinition.version?: string | undefined
version
: "1.0.0",
PluginDefinition.schemaVersion?: number | undefined
schemaVersion
: 1,
PluginDefinition.migrations?: readonly PluginMigration[] | undefined
migrations
: [
{
PluginMigration.id: string
id
: "0001-create-todo-items",
PluginMigration.schemaVersion: number
schemaVersion
: 1,
PluginMigration.sql: string
sql
: `
CREATE TABLE todo_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
`,
},
],
PluginDefinition.services?: Readonly<Record<string, PluginServiceDefinition>> | undefined
services
: {
"todo.list": {
PluginServiceDefinition.input: ValueSchema<unknown>
input
:
any
listInput
,
PluginServiceDefinition.output: ValueSchema<unknown>
output
:
any
todoListOutput
,
PluginServiceDefinition.handler(input: any, context: PluginServiceContext): unknown | Promise<unknown>
handler
(
_input: any
_input
, {
database: Database
database
}) {
const
const rows: any
rows
=
database: Database
database
.
any
query
<
type TodoRow = /*unresolved*/ any
TodoRow
, []>(
`SELECT id, title, completed, created_at AS createdAt
FROM todo_list_items
ORDER BY completed ASC, id DESC`,
)
.
any
all
();
return
const rows: any
rows
.
any
map
(
any
toTodoItem
);
},
},
"todo.create": {
PluginServiceDefinition.input: ValueSchema<unknown>
input
:
const createInput: z.ZodObject<{
title: z.ZodString;
}, z.core.$strict>
createInput
,
PluginServiceDefinition.output: ValueSchema<unknown>
output
:
any
todoOutput
,
PluginServiceDefinition.handler(input: any, context: PluginServiceContext): unknown | Promise<unknown>
handler
(
input: any
input
, {
database: Database
database
}) {
const
const createdAt: string
createdAt
= new
var Date: DateConstructor
new () => Date (+3 overloads)
Date
().
Date.toISOString(): string

Returns a date as a string value in ISO format.

toISOString
();
const
const result: any
result
=
database: Database
database
.
any
query
(
`INSERT INTO todo_list_items (title, completed, created_at)
VALUES (?, 0, ?)`,
)
.
any
run
(
input: any
input
.
any
title
,
const createdAt: string
createdAt
);
return {
id: number
id
:
var Number: NumberConstructor
(value?: any) => number

An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

Number
(
const result: any
result
.
any
lastInsertRowid
),
title: any
title
:
input: any
input
.
any
title
,
completed: boolean
completed
: false,
createdAt: string
createdAt
,
};
},
},
},
PluginDefinition.actions?: Readonly<Record<string, ActionDefinition>> | undefined
actions
: {
"todo.create": {
ActionDefinition.service: string
service
: "todo.create",
ActionDefinition.bodyLimitBytes?: number | undefined
bodyLimitBytes
: 512,
ActionDefinition.rateLimit?: {
readonly limit: number;
readonly windowMs: number;
} | undefined
rateLimit
: {
limit: number
limit
: 20,
windowMs: number
windowMs
: 60_000 },
ActionDefinition.timeoutMs?: number | undefined
timeoutMs
: 2_000,
},
"todo.toggle": {
ActionDefinition.service: string
service
: "todo.toggle",
ActionDefinition.bodyLimitBytes?: number | undefined
bodyLimitBytes
: 128,
ActionDefinition.rateLimit?: {
readonly limit: number;
readonly windowMs: number;
} | undefined
rateLimit
: {
limit: number
limit
: 60,
windowMs: number
windowMs
: 60_000 },
ActionDefinition.timeoutMs?: number | undefined
timeoutMs
: 2_000,
},
},
};
},
});

扩展内容构建

在核心「获取内容 → 构建快照 → 加载插件能力」的流程中,插件可以在多个阶段介入。例如一个 callout 插件可以先在 remark 阶段产生语义节点,再在 rehype 阶段输出静态标记:

import {
function definePlugin(definition: PluginDefinition): PluginDefinition (+1 overload)
definePlugin
} from "diitey";
import
function remarkDirective(): undefined

Add support for generic directives.

Notes

Doesn’t handle the directives: create your own plugin to do that.

@returnsNothing.

remarkDirective
from "remark-directive";
export default
function definePlugin(definition: PluginDefinition): PluginDefinition (+1 overload)
definePlugin
({
PluginDefinition.name?: string | undefined
name
: "callout",
PluginDefinition.markdown?: {
readonly remarkPlugins?: readonly Pluggable[];
readonly rehypePlugins?: readonly Pluggable[];
readonly bodyTransforms?: readonly MarkdownBodyTransform[];
} | undefined
markdown
: {
remarkPlugins?: readonly Pluggable[] | undefined
remarkPlugins
: [
function remarkDirective(): undefined

Add support for generic directives.

Notes

Doesn’t handle the directives: create your own plugin to do that.

@returnsNothing.

remarkDirective
,
any
remarkCallout
],
rehypePlugins?: readonly Pluggable[] | undefined
rehypePlugins
: [
any
rehypeCallout
],
},
});

Preact 服务端渲染 + Island

我选择用 Preact 做 SSR:相比 Next.js 这类重型方案,它更轻量、底层。对于页面上需要交互的部分,我也自己实现了一套 island 系统,在浏览器中局部 hydrate。

管理页面

我本来不想给这个系统加管理页。但做评论插件时发现,没有管理还是不行。于是设计成这样:核心只提供受鉴权的 /_admin 入口与接口;默认管理面没有业务功能,只负责登录,以及进入各插件自己的管理页。所有管理 UI 都由插件通过 island 动态加载。

这样一来,浏览量统计这些别家系统里的基础功能,也完全可以用自定义插件实现。

默认站点

核心开发完成后,我做了一套默认主题,名为 void。也是本博客现在使用的主题。

这是一个注重文字本身,简洁又现代的主题,没有过多花里胡哨的部分,但充满精致的细节。

我采用衬线体标题 + 非衬线体正文的形式,既美观又保证了阅读体验。右侧滚动条经过了重新设计,融入 TOC 组件,优雅不突兀(其实是从 Grok 官网抄来的)。主页还有一个巨大的黑洞作为装饰。另外还有像页面切换的动画、超链接卡片等组件的支持也一应俱全。

主题源码在 templates/default-site/themes/void

默认站点还带了三个实用插件:


目前框架仍在开发中,主要为自用,不保证稳定,也尚未发布包。后面会持续优化。

7.30 更新:

后来我发现自己做框架的话简直就是维护地狱,于是妥协转到了 Astro,拥抱静态站点 + Waline 评论的形式😅 后续会把这个 Astro 站点开源,敬请期待吧。

我发现 Waline 的内存占用也太高了,早知道换 Go 写的 Artalk 了。。

8.11 更新:

Waline 不仅占用高,前端还会打包进一整个 Vue.js,包体积巨大,同时评论发送时的校验和邮件通知竟然都是串行执行的,导致卡顿严重。实在不能接受,在今天换到了 Artalk。