自 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: DateConstructornew () => 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.
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.
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。
Comments