置顶系列 · Inkwell 源码解析

Inkwell 工程全解:一个 HN 风格博客的完整实现

从 Next.js 前台、Fastify API 到 SQLite 数据层与 Docker 部署,拆解这套可自托管博客的每一层。

Inkwell工程全解:一个 HN 风格博客的完整实现#

这不是 README 的复制,而是一次从浏览器请求到页面渲染、从 SQLite 写入到 Docker 上线的完整走读。

Inkwell 是一个可以自托管的个人博客系统。它借用了 Hacker News 的视觉语言:橙色标题栏、等宽排名、纸张底色,以及一种「把注意力还给内容」的克制。但在界面之下,它是一套完整的前后端分离工程:Next.js 负责公开站点与 SSG/ISR,React 负责写作后台,Fastify 提供 REST API,SQLite 保存内容与检索索引,Nginx 与 Docker Compose 负责把这一切装进一台服务器。

这篇文章会沿着数据流,把源码拆成八个部分:整体架构、数据层、渲染管线、双模式编辑器、前台与缓存、评论与社交、搜索与 SEO、部署与安全。


1. 为什么是 HN 风格#

Hacker News 的界面几乎可以用「过时」来形容:没有大图、没有卡片、没有滚动动画。但它有一个很少被认真讨论的优点:信息密度与阅读顺序高度一致。

首页列表只有四样东西:

  • 名次
  • 标题
  • 来源
  • 作者、时间、评论数

Inkwell 保留了这个结构,只做了三处扩展:

  1. 用橙色热力图记录写作活动,而不是展示访问量;
  2. 给每篇文章加上阅读时长、点赞与标签;
  3. 用 IsR/按需再生成替代「每次更新都全站重建」。

界面的颜色也尽量少:

:root {
  --bg: #f6f6ef;
  --text: #1a1a1a;
  --muted: #828282;
  --accent: #ff6600;
}

只有三种主要颜色,却足以支撑首页、文章、归档、标签、评论和后台。设计上的克制,反而降低了工程复杂度:不需要为每个页面维护一套视觉系统。


2. 整体架构:三个应用,两个共享包#

Inkwell 是一个 pnpm monorepo:

inkwell/
├── apps/
│   ├── api/          Fastify 5 + SQLite + 渲染服务
│   ├── web/          Next.js 16 公开站点
│   └── admin/        Vite + React 19 写作后台
├── packages/
│   ├── shared/       共享类型、状态与工具
│   └── content/      Markdown / 块渲染管线
├── deploy/           Nginx 与网关镜像
├── docker-compose.yml
└── scripts/          冒烟测试、备份、OG 字体

请求路径大致如下:

flowchart LR
B[Browser] --> G[Nginx :80]
G --> W[Next.js :3000]
G --> A[Fastify :4000]
G --> S[Admin SPA]
W --> A
A --> D[(SQLite)]
A --> U[/data/uploads/]
  • Nginx 只做三件事:反向代理、托管后台静态文件、统一入口。
  • Next.js 负责公开页面。文章页与分类页使用 SSG + ISR,发布时通过内部接口按路径失效。
  • Fastify 负责所有写操作、评论、搜索、Feed、统计与后台 API。
  • Admin SPA 不参与公开渲染,只通过 /api/admin/* 与接口通信。

这种拆分的好处是:前台可以独立扩容或替换,后台可以独立打包,API 可以同时服务小程序、App 或自动化脚本。

为什么前台不直接读数据库#

Next.js 完全可以在服务端直接访问 SQLite,但 Inkwell 选择让前台只依赖 HTTP API。这样做有三个直接收益:

  1. 前台可以部署在任意位置,甚至改成纯静态托管;
  2. 数据库的读写只发生在 API 进程,减少锁竞争;
  3. 所有内容规则(密码文章、可见性、置顶)只需要在 API 维护一次。

代价是构建期需要 API 可用。因此 Docker 部署时,前台容器会等待 API 健康后再执行 next build,避免把空数据库构建进静态页面。


3. 数据层:SQLite 不是玩具#

Inkwell 使用 Node.js 内置的 node:sqlite,没有 ORM,也没有原生编译依赖。数据库文件就是 data/inkwell.db。

核心表:

表 作用
posts 文章正文、状态、SEO、密码哈希、统计字段
post_versions 每次保存的版本快照
categories / tags / series 分类、标签、系列
post_tags 文章与标签的多对多关系
comments 评论、审核状态、反垃圾评分
notes 微语
friends / shelf 友链与书影音
subscribers / outbox 订阅者与邮件队列
pageviews / activity 访问统计与写作活动
settings 站点配置
posts_fts FTS5 trigram 全文索引

启动时执行:

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;

WAL 让读操作不被写操作阻塞,busy_timeout 则避免瞬时并发直接报错。

3.1 为什么不用 ORM#

这个项目的数据模型不复杂,查询模式也很固定。手写 SQL 带来的好处是:

  • 列表页只查需要的字段,不加载正文;
  • 热榜排序可以直接写进 SQL;
  • FTS5、json_group_array 等 SQLite 特性不需要 ORM 适配;
  • 备份、导出和导入都可以直接操作表。

代价是需要自己保证参数绑定和字段映射。为此,项目里有一个很薄的 db.ts:

export const db = new DatabaseSync(config.dbFile);
 
export function all<T>(sql: string, ...params: any[]): T[] {
  return db.prepare(sql).all(...params) as T[];
}

所有 SQL 都使用 ? 占位符,避免拼接用户输入。

3.2 文章版本#

每次保存都会写入 post_versions,但只有内容真正变化时才增加版本号:

const last = get<{ v: number }>(
  "SELECT COALESCE(MAX(version), 0) AS v FROM post_versions WHERE post_id = ?",
  postId,
);

恢复版本不会覆盖历史,而是把旧内容作为一次新的保存写回,因此「恢复」本身也可以被恢复。

3.3 全文索引#

搜索使用 FTS5 的 trigram 分词:

CREATE VIRTUAL TABLE posts_fts USING fts5(
  post_id UNINDEXED,
  title,
  excerpt,
  content_plain,
  tags,
  tokenize = 'trigram'
);

trigram 对中文比较友好;两字查询会自动退化为 LIKE。密码文章的正文不会写入索引,避免通过搜索泄露内容。


4. 渲染管线:Markdown 与块编辑器共用一条路#

Inkwell 有两个编辑器,但只有一条渲染管线。

Markdown ─┐
          ├─→ blocksToMarkdown / remark → rehype → HTML
Blocks ───┘

块编辑器不会直接生成 HTML。它先把块转换成 Markdown,再交给同一套 unified 处理:

const result =
  format === "blocks"
    ? await renderBlocks(blocks)
    : await renderMarkdown(markdown);

这条管线包含:

  • remark-gfm:表格、删除线、任务列表
  • remark-math + rehype-katex:行内与块级公式
  • rehype-pretty-code + Shiki:代码高亮
  • remark-directive:提示框、嵌入、自定义组件
  • 自定义 Mermaid 插件:把 mermaid 代码块变成 <pre class="mermaid">
  • rehype-slug / rehype-autolink-headings:标题锚点
  • rehype-sanitize:评论专用严格清洗

4.1 代码高亮#

Shiki 在服务端完成高亮,浏览器只负责显示:

```ts
type PostStatus = "draft" | "published" | "scheduled" | "private";
```

双主题输出会同时写入浅色与深色变量,切换主题时不需要重新高亮。

4.2 公式#

质能方程 $E = mc^2$,行间公式:
 
$$
\frac{1}{n}\sum_{i=1}^{n} x_i
$$

KaTeX 在服务端渲染成静态 HTML 与 MathML,浏览器只需要加载 CSS。

4.3 Mermaid#

```mermaid
flowchart TD
  A[草稿] --> B[发布]
  B --> C[ISR 失效]
  C --> D[读者看到新版本]
```

Mermaid 体积较大,因此前台只在页面真的存在图表时动态 import(),不会拖慢普通文章。

4.4 自定义组件#

指令语法不需要写 HTML:

:::callout{type="tip" title="提示"}
这是一段提示。
:::
 
:::youtube{id="dQw4w9WgXcQ"}
:::
 
:::gallery{images="/uploads/a.jpg|/uploads/b.jpg"}
:::
 
:::component{name="Counter" start="2026" label="从这一年开始"}
:::

服务端把组件渲染为一个带 data-component 与 data-props 的占位节点,浏览器端的 CustomBlocks 再按注册表挂载 React 组件。这样既保留了 SSR 的 HTML 语义,又能嵌入真正有状态的交互组件。


5. 双模式编辑器#

后台没有试图用一套 UI 满足所有写作习惯,而是提供了两种模式。

5.1 Markdown 分屏#

Markdown 编辑器基于 CodeMirror 6:

  • 工具栏:标题、粗体、引用、列表、代码、链接、图片、公式、提示框、Mermaid
  • 分屏预览由 API 的 /api/admin/render 生成
  • 预览与最终文章使用完全相同的管线,避免「编辑器里好看、发出去变形」
const data = await api.post<{ html: string }>("/api/admin/render", {
  format,
  contentMd,
  contentBlocks: blocks,
});

5.2 块编辑器#

块编辑器支持段落、标题、引用、代码、图片、列表、提示框、公式、Mermaid、分割线、嵌入、画廊与 React 组件。

每个块都有稳定 id,可以拖拽排序、上下移动、删除。Enter 新建段落,Backspace 删除空块,/ 可以快速打开块类型菜单。

5.3 保存与版本#

  • ⌘/Ctrl + S 保存
  • 30 秒无操作自动保存
  • 每次保存生成版本快照
  • 内容未变化时不新增版本
  • 发布前不需要额外「保存」一次,发布动作会先写入当前内容

密码保护也在同一张表单里:留空表示保持现有密码,勾选/取消可以设置或移除。


6. 前台:HN 列表、热力图与 ISR#

6.1 热榜排序#

首页不是单纯的「最新」,而是把阅读、点赞、评论和新鲜度组合成一个分数:

ORDER BY p.pinned DESC,
  (
    p.views + p.likes * 8
    + (SELECT COUNT(*) FROM comments c
       WHERE c.post_id = p.id AND c.status = 'approved') * 6
    + MAX(0, 96 - (julianday('now') - julianday(p.published_at))) * 3
  ) DESC

置顶永远最前,其余按热度与时间衰减排序。

6.2 写作热力图#

热力图的数据来自 activity 表:

CREATE TABLE activity (
  day TEXT PRIMARY KEY,
  posts INTEGER DEFAULT 0,
  notes INTEGER DEFAULT 0,
  edits INTEGER DEFAULT 0
);

发布文章、写微语、保存修订都会累加。前端按 53 周 × 7 天渲染,颜色分为 5 档,悬停显示日期与次数。

它统计的是「创作活动」,不是访问量;页面访问另有一张 pageviews 表,使用匿名访客哈希,不写 Cookie。

6.3 SSG、ISR 与按需失效#

文章页:

export const revalidate = 60;
 
export async function generateStaticParams() {
  const data = await getPosts({ pageSize: 50, sort: "new" });
  return data.items.map((post) => ({ slug: post.slug }));
}

发布时,API 调用前台:

await fetch(`${WEB_URL}/api/revalidate?secret=...`, {
  method: "POST",
  body: JSON.stringify({
    paths: ["/", `/post/${slug}`, "/archive"],
    tags: ["posts", `post:${slug}`],
  }),
});

Next.js 执行 revalidatePath 与 revalidateTag。前台不可达时,发布不会被阻塞;下次构建或过期时会自然恢复。


7. 评论、微语与轻社交#

7.1 自建评论#

自建评论保存在 SQLite,支持:

  • 嵌套回复
  • Markdown
  • 表情
  • 审核后展示
  • 后台批量通过/标记垃圾

反垃圾由多层组成:

  1. 蜜罐字段 website2
  2. 关键词与链接数量评分
  3. 10 分钟内提交频率
  4. 重复内容检测
  5. Akismet(可选,调用失败自动降级)

评论 HTML 会经过严格的 rehype-sanitize,不会把用户输入直接交给 dangerouslySetInnerHTML。

7.2 托管评论#

在后台设置中切换提供方即可接入:

提供方 配置
Giscus repo、repoId、category、categoryId
Waline serverURL
Twikoo envId
Artalk server、site

前台根据配置动态加载脚本,不把任何第三方 SDK 打进主包。

7.3 微语与友链#

微语是短状态,使用与评论相同的 Markdown 清洗管线,支持心情、图片和置顶。

友链保存 HTTP 状态与延迟,API 每隔数小时检查一次超过 24 小时未检测的站点。检测失败不会删除友链,只会显示红色状态点。


8. 搜索、SEO、订阅与统计#

8.1 搜索#

搜索有两层:

  • 浏览器:/api/search-index 生成 JSON,Fuse.js 做标题、标签、摘要、正文的模糊匹配
  • 服务端:FTS5 trigram;短查询回退到 LIKE

快捷键 ⌘K / Ctrl+K,或直接按 /。

8.2 SEO#

  • 每篇文章可自定义 Slug、标题、描述、canonical、OG 图与 noindex
  • sitemap.xml 从 /api/sitemap-data 生成
  • robots.txt 禁止抓取 /admin/、/api/ 与 /search
  • 文章页输出 JSON-LD:BlogPosting + BreadcrumbList
  • /post/[slug]/opengraph-image 使用 next/og 动态生成 1200×630 卡片

OG 卡片使用一份提交在仓库里的中文子集字体,避免服务端渲染时中文变成方块。新增生僻字标题后,可以运行:

node scripts/make-og-font.mjs

重新生成字体子集。

8.3 RSS / Atom / Newsletter#

  • /feed.xml:RSS 2.0,包含 content:encoded
  • /atom.xml:Atom
  • Newsletter:订阅者写入 subscribers,配置 SMTP 后发送确认邮件与群发
  • 未配置 SMTP 时,邮件进入 outbox,后台可以看到队列

8.4 统计#

内置统计只记录:

  • 页面路径
  • 访客哈希
  • 来源
  • 时间

不保存原始 IP,不写 Cookie,支持 Do Not Track。后台展示 14 天 PV/UV 与写作活跃度,也可以挂载 Umami 或 Plausible。


9. 部署:三个容器与一条命令#

docker-compose.yml 只有三个服务:

services:
  api:
    build: ./apps/api
    volumes:
      - ./data:/data
  web:
    build: ./apps/web
    environment:
      API_URL: http://api:4000
  gateway:
    build:
      context: .
      dockerfile: deploy/Dockerfile.gateway
    ports:
      - "80:80"

9.1 为什么前台在启动时构建#

docker build 阶段 API 通常不可达,如果此时执行 next build,SSG 只能拿到空数据。Inkwell 的做法是:

  1. 先启动 API,等待健康检查;
  2. Web 容器启动后执行 next build;
  3. 构建完成后启动 next start;
  4. Nginx 等待 Web 健康后启动。

首次启动会慢一些,但首页、归档与文章页能拿到真实内容。

9.2 数据与备份#

所有持久化数据在 ./data:

data/
├── inkwell.db
├── uploads/
└── backups/

API 每天写入一份 JSON 快照,后台也可以手动备份,或者执行:

sh scripts/backup.sh

恢复时停止容器、替换 data/,再重新启动。


10. 安全设计#

上线前必须修改:

  • ADMIN_PASSWORD
  • JWT_SECRET
  • REVALIDATE_SECRET

已内置的保护:

  • scrypt 密码哈希
  • HMAC JWT,7 天过期
  • 登录限流 5 分钟 10 次
  • 评论限流 10 分钟 6 次
  • 全局限流
  • 评论 HTML 清洗
  • 上传类型白名单(JPG/PNG/GIF/WebP/AVIF、音频、PDF)
  • 密码文章正文不进入搜索、Feed、sitemap 与索引
  • Nginx 安全响应头

11. 如何二次开发#

加一个块类型#

  1. 在 packages/shared 的 Block 联合类型中增加字段;
  2. 在 packages/content/src/blocks.ts 中把它转成 Markdown;
  3. 在后台 block-editor.tsx 增加编辑 UI;
  4. 在服务端渲染管线中按需扩展。

加一个自定义组件#

在 apps/web/components/custom-blocks.tsx 的 registry 中注册:

const registry = {
  Counter,
  Rating,
  YouTube,
  Bilibili,
  Embed,
  Music,
  Gallery,
  MyWidget,
};

然后在文章中写:

:::component{name="MyWidget" foo="bar"}
:::

改主题#

公开站点的样式集中在 apps/web/app/globals.css,颜色、字号、间距都使用 CSS 变量。改 :root 里的 --accent、--bg、--text 就能得到一套完全不同的视觉,而不需要动组件结构。


12. 边界与后续#

Inkwell 不是一个「什么都能做」的 CMS。它刻意保留了边界:

  • 单管理员:没有多用户协作与复杂角色
  • SQLite:适合单机与读多写少;多实例部署需要换数据库
  • REST,不是 GraphQL:需要 GraphQL 时可以在 Fastify 前加一层网关
  • 没有付费会员:Newsletter 有订阅与群发,但没有支付与会员等级
  • 没有原生 App:通过 REST API 与 Webhook 对接

如果继续演进,我会优先做三件事:

  1. 更完善的媒体处理(缩略图、WebP 自动转换);
  2. 可插拔的搜索适配器(Meilisearch / Typesense);
  3. 更细的版本 diff,而不只是快照列表。

结语#

Inkwell 的复杂度并不在某个单点技术上,而在「让这些部分彼此不打架」:

  • Markdown 与块编辑器共享一条渲染管线;
  • 前台与后台共享同一套类型;
  • 静态页面与按需失效共享同一份内容;
  • Docker 把构建顺序、数据卷与反向代理收进一条命令。

如果你也在维护一个个人博客,希望这份源码能提供一点参考:把界面做轻,把数据做稳,把发布做成一条可重复执行的路径。

站

站长

写代码,也写代码之外的事。相信长期主义和缓慢的积累。

评论

0 条讨论

正在加载评论…

    留下评论

    Markdown · 审核后展示