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

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

现有建站框架的不足

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

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

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

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

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

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

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

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

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

主题职责

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

export default defineTheme({
  config: minimalThemeConfig,
  setup(config) {
    return {
      document: "pages/document",
      styles: "styles",
      collections: {
        articles: collection({
          from: "articles/*/*.md",
          where: { draft: { not: true } },
          orderBy: [{ field: "created", direction: "desc" }],
          schema: {
            title: "string",
            tags: "string[]?",
            draft: "boolean?",
          },
        }),
      },
      routes: [
        route(
          "/",
          page("home", {
            items: {
              collection: "articles",
              paginate: config.articlePageSize,
            },
          }),
        ),
        route(
          "/writing/:year/:slug",
          page("article", {
            item: {
              collection: "articles",
              match: "articles/:year/:slug.md",
            },
          }),
        ),
      ],
    };
  },
});

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

插件职责

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

提供服务

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

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

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

export default definePlugin({
  config: todoListConfig,
  setup(config) {
    const createInput = z
      .object({
        title: z.string().trim().min(1).max(config.maxTitleLength),
      })
      .strict();

    return {
      id: "todo-list",
      version: "1.0.0",
      schemaVersion: 1,

      migrations: [
        {
          id: "0001-create-todo-items",
          schemaVersion: 1,
          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
            );
          `,
        },
      ],

      services: {
        "todo.list": {
          input: listInput,
          output: todoListOutput,
          handler(_input, { database }) {
            const rows = database
              .query<TodoRow, []>(
                `SELECT id, title, completed, created_at AS createdAt
                 FROM todo_list_items
                 ORDER BY completed ASC, id DESC`,
              )
              .all();
            return rows.map(toTodoItem);
          },
        },

        "todo.create": {
          input: createInput,
          output: todoOutput,
          handler(input, { database }) {
            const createdAt = new Date().toISOString();
            const result = database
              .query(
                `INSERT INTO todo_list_items (title, completed, created_at)
                 VALUES (?, 0, ?)`,
              )
              .run(input.title, createdAt);
            return {
              id: Number(result.lastInsertRowid),
              title: input.title,
              completed: false,
              createdAt,
            };
          },
        },
      },

      actions: {
        "todo.create": {
          service: "todo.create",
          bodyLimitBytes: 512,
          rateLimit: { limit: 20, windowMs: 60_000 },
          timeoutMs: 2_000,
        },
        "todo.toggle": {
          service: "todo.toggle",
          bodyLimitBytes: 128,
          rateLimit: { limit: 60, windowMs: 60_000 },
          timeoutMs: 2_000,
        },
      },
    };
  },
});

扩展内容构建

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

import { definePlugin } from "diitey";
import remarkDirective from "remark-directive";

export default definePlugin({
  name: "callout",
  markdown: {
    remarkPlugins: [remarkDirective, remarkCallout],
    rehypePlugins: [rehypeCallout],
  },
});

Preact 服务端渲染 + Island

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

管理页面

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

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

默认站点

核心开发完成后,我做了一套默认主题,名为 void。这是一个注重内容、注重文字的主题,没有过多花里胡哨的部分,但充满精致的细节。也是本博客现在使用的主题。源码在 templates/default-site/themes/void

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


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

github 链接:https://github.com/Aziteee/diitey