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 保留了这个结构,只做了三处扩展:
- 用橙色热力图记录写作活动,而不是展示访问量;
- 给每篇文章加上阅读时长、点赞与标签;
- 用 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。这样做有三个直接收益:
- 前台可以部署在任意位置,甚至改成纯静态托管;
- 数据库的读写只发生在 API 进程,减少锁竞争;
- 所有内容规则(密码文章、可见性、置顶)只需要在 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
- 表情
- 审核后展示
- 后台批量通过/标记垃圾
反垃圾由多层组成:
- 蜜罐字段
website2 - 关键词与链接数量评分
- 10 分钟内提交频率
- 重复内容检测
- 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 的做法是:
- 先启动 API,等待健康检查;
- Web 容器启动后执行
next build; - 构建完成后启动
next start; - Nginx 等待 Web 健康后启动。
首次启动会慢一些,但首页、归档与文章页能拿到真实内容。
9.2 数据与备份#
所有持久化数据在 ./data:
data/
├── inkwell.db
├── uploads/
└── backups/API 每天写入一份 JSON 快照,后台也可以手动备份,或者执行:
sh scripts/backup.sh恢复时停止容器、替换 data/,再重新启动。
10. 安全设计#
上线前必须修改:
ADMIN_PASSWORDJWT_SECRETREVALIDATE_SECRET
已内置的保护:
- scrypt 密码哈希
- HMAC JWT,7 天过期
- 登录限流 5 分钟 10 次
- 评论限流 10 分钟 6 次
- 全局限流
- 评论 HTML 清洗
- 上传类型白名单(JPG/PNG/GIF/WebP/AVIF、音频、PDF)
- 密码文章正文不进入搜索、Feed、sitemap 与索引
- Nginx 安全响应头
11. 如何二次开发#
加一个块类型#
- 在
packages/shared的Block联合类型中增加字段; - 在
packages/content/src/blocks.ts中把它转成 Markdown; - 在后台
block-editor.tsx增加编辑 UI; - 在服务端渲染管线中按需扩展。
加一个自定义组件#
在 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 对接
如果继续演进,我会优先做三件事:
- 更完善的媒体处理(缩略图、WebP 自动转换);
- 可插拔的搜索适配器(Meilisearch / Typesense);
- 更细的版本 diff,而不只是快照列表。
结语#
Inkwell 的复杂度并不在某个单点技术上,而在「让这些部分彼此不打架」:
- Markdown 与块编辑器共享一条渲染管线;
- 前台与后台共享同一套类型;
- 静态页面与按需失效共享同一份内容;
- Docker 把构建顺序、数据卷与反向代理收进一条命令。
如果你也在维护一个个人博客,希望这份源码能提供一点参考:把界面做轻,把数据做稳,把发布做成一条可重复执行的路径。
评论
0 条讨论正在加载评论…