乐于分享
好东西不私藏

Pigsty作者力作!文档站建站与维护,从此只要markdown

Pigsty作者力作!文档站建站与维护,从此只要markdown

周五下午 4 点,内网文档站上线前的最后一次检查。银行安全团队发来三行红字:mermaid 图表引用的公共 CDN 已被防火墙拦截;搜索框后面连的 Algolia 需要出网审批,流程两周起步;公式的字体来自 Google Fonts,想都别想。你转身想本地构建一版全离线的,结果新同事的 npm install 卡在 node-gyp 上——他的 Node 是 18,项目要 20。

一个文档站,什么正事还没干,先被工具链外部服务按在地上摩擦。

如果我告诉你,有个开源项目,0 个 npm 依赖、不连任何 CDN、断网也能搜索、画图、渲染公式,连喂给 AI 的 llms.txt 都顺手生成好了——你信吗?

它叫 Oink,猪叫声的那个"哼哼"。


一、文档站的三座大山

写文档的人只想写文档,但 2026 年搭一个文档站,你实际要面对的是这个:

第一座山,工具链绑架。 主流文档框架都是好东西,但它们把文档站变成了一个前端工程:装依赖、锁版本、配编译。文档团队里往往没人专职管这个,于是每次升级都是一次冒险。

第二座山,外部服务依赖。 公共 CDN 在内网环境等于不存在,搜索服务要出网审批,字体服务看运气。你以为自己在"写文档",其实是在替第三方服务做可用性运维。

第三座山最隐蔽:内容锁死。 你的 Markdown 进去,出来的是一堆带导航、带侧边栏、带埋点脚本的 HTML——甚至很多框架要求你在 Markdown 里直接写组件,内容变成了某个前端框架的私有方言。读者想干净地复制一段?想把这本文档打印成手册?想让 AI Agent 好好读一遍?全都得费劲。

有没有一个项目,同时把这三座山搬走?


二、OINK 是什么?

OINK 文档站

Oink 是 Pigsty(企业级 PostgreSQL 发行版)作者Vonng推出的开源文档框架——用他自己的话说,不只是一个 Hugo 主题,而是一套文档发行版。

名字的来历很直白:一边是文档与墨水的 Ink,另一边是 Pigsty 宇宙里小猪的叫声 Oink。README 里那句 "Open. Indexed. Navigable. Knowledge." 就当是附赠的彩蛋。

一句话定义:只用 Markdown 和一个 Hugo 二进制——不要前端全家桶——统一构建文档、博客、书籍、发布页、Landing Page 和 API Reference,输出给浏览器、打印机和 AI Agent。

这个项目最硬的背书不是任何宣传语,而是作者的迁移清单:从 Pigsty 的中英文档、Silo、PIG、SOW、PG Exporter,到公司首页、个人博客,再到《设计数据密集型应用》(DDIA)这样的多语言书籍——18 个生产网站,全部跑在同一套框架上。

迁移到 Oink 的 18 个网站

从上千页的大型技术文档到只有几页的小工具,从博客到书籍到 Landing Page,全部运行在 Oink 上。

用 Vonng 自己的话说:一个作者对工具最高级别的评价,不是 README 里写了多少卖点,而是敢不敢把自己的生产环境全部押上去。现在他押了。


三、开箱效果展示

说再多架构,不如直接看效果。下面这些截图全部来自作者自己在维护的生产站点,不是特意摆拍的 Demo。一套框架,六种内容形态,共享同一套设计语言、搜索、语言切换和输出体系。

3.1 文档: 页内纲要

文档页

左侧目录树、面包屑、右侧页内纲要一应俱全——右侧那条带进度高亮和移动圆点的纲要轨道,是主题自研的 outline 运行时(SVG 轨道 + clip-path 高亮),不依赖 Bootstrap 的 scrollspy。

3.2 博客:沉浸式 Hero 长文

博客的沉浸式 Hero 页面

0.6.0 引入的博客形态:全幅头图打底、作者档案署名(冯若航 / Vonng / Pigsty)、系列文章与标签体系。列表、卡片、表格三种索引形态可以随读者喜好切换。

3.3 发布与下载:数据驱动的 Release 页

发布与下载页

一份结构化 YAML 数据,自动生成发布卡片、版本历史、各平台安装命令、下载资产列表和 SHA-256 校验和。发布状态不再散落在手写 HTML 里,而是可检查、可复用的数据。

3.4 Landing Page:服务端渲染的产品首页

Landing Page

内置一组服务端渲染的首页区块:Hero、功能板块、指标、FAQ、行动入口,全部用数据和 Markdown 组合。Oink 官网和 PGSTY 公司首页,本身就是用这套 Landing 系统搭出来的。

3.5 内容组件:原生 Markdown 的写法与效果

步骤列表:写法与效果

步骤列表的上半部分是渲染效果,下半部分就是它的源码——一个普通的有序列表,加一行 {.steps} 属性。离开 Oink,它仍然是任何 Markdown 工具都能读懂的列表。

文件树组件

文件树同理:渲染出来是带文件类型图标、对齐注释、可折叠目录的交互组件;源码是一个接近纯文本的缩进围栏。这就是"能用原生 Markdown 表达的,绝不发明新语法"落到实处的样子。


四、凭什么是它?跟主流文档框架掰手腕

先说 Oink 的定位。它不是"又一个漂亮主题",而是把整套文档交付问题打包解决:内容怎么组织、文档博客书籍如何共存、搜索导航多语言怎么做、同一份内容如何输出给浏览器打印机和 AI、网站如何构建检查部署升级。

跟主流选手逐项对比:

维度
Docusaurus
VitePress
Fumadocs
Docsy
Hextra
Oink
生成器
React/Node
Vue/Node
Next.js
Hugo
Hugo
Hugo
需要 Node 工具链
断网/内网完整可用
⚠️
⚠️
⚠️
中文搜索
⚠️ 需配分词
⚠️
⚠️
✅ 内置双引擎
构建时渲染公式
❌ 客户端
⚠️
⚠️
✅ 零 JS
图表按需加载
⚠️ 全局引入
⚠️
⚠️
⚠️
⚠️
✅ 逐页判定
输出格式
HTML
HTML
HTML
HTML+打印
HTML
HTML+打印+MD+llms.txt
API 文档(Swagger)
插件
插件
插件
⚠️
✅ 内置
32 语言 i18n
⚠️
⚠️
⚠️
⚠️
✅ 中文全审校
内容形态
MDX 混杂组件
MDX 可选
MDX
短代码
短代码
原生 Markdown 优先

看清楚格局了:功能密度上它对标 Docusaurus 这类"全家桶",交付形态上它比 Hugo 原生主题还干净,内容哲学上它比谁都固执——能用原生 Markdown 表达的,绝不发明新语法。

这点值得展开。步骤列表在 Oink 里就是一个普通有序列表加一行属性;文件树就是一个接近纯文本的围栏块;Callout 用普通引用块,字段说明用普通表格。离开 Oink,这些内容仍然是任何 Markdown 工具都能读懂的文本。 用 Vonng 的话说:框架总会过时,内容应该活得比框架更久——样式控制不应该大面积侵入内容。


五、源码深度拆解

5.1 架构全景

Oink 的核心设计一句话就能讲完:能挪进构建时的,绝不留给运行时;运行时确实需要的,只给用得上的页面。

注意那个紫色的运行时层——这是 Oink 和几乎所有文档主题拉开差距的地方,下面细拆。

5.2 核心模块速览

模块
职责
规模
layouts/_markup/
16 个渲染钩子:代码块、公式、图片、表格逐元素接管
核心
layouts/_partials/
80 个局部模板:壳、导航、搜索、页脚、作者
1.17 万行模板
assets/js/
30 个原生 JS 模块:壳交互、命令面板、搜索引擎
6108 行,零框架零转译
assets/scss/
61 个 SCSS 文件,全套设计令牌 + 暗色 + 打印
1.33 万行
assets/third_party/
26 个依赖:bootstrap、mermaid、katex、lunr、echarts……
全部本地化
bin/33 个 Python 检查器
:契约、安全、i18n、golden 比对
1.67 万行
i18n/
32 个语言包,zh-cn/zh/zh-tw 全量审校
34 个 YAML
tests/
JS 单测 + 181 页回归夹具 + 四态 golden
4400 行 JS

内置的内容组件覆盖了工程文档的全谱系——图表、公式、终端录像、思维导图、文件树、图廊,以及断网可用的 Swagger / Redoc API 文档:

Oink 的 21 类内容组件

这些能力全部按需加载:页面没用到的组件,一个字节的运行时都不会进页面。

5.3 标志位驱动的按需加载:图表不是全局背锅的

为什么一个只有几页图表的文档站,要为每一页付 mermaid 的加载成本? Oink 的答案是一个两段式机关。

第一段,渲染钩子在渲染元素时顺手登记标志位——mermaid 的钩子只有一行有效代码:

{{ .Page.Store.Set "hasmermaid" true -}}<pre class="mermaid">  {{- .Inner -}}</pre>

页面里出现过 mermaid 围栏,Page.Store 里就多了一个 hasmermaid 标志;没有出现过,标志就不存在。

第二段,scripts.html 在页面尾部按标志位拼装 JS 包(节选):

{{ $jsArray := slice -}}{{ if $hasPlantuml -}}  {{ $jsArray = $jsArray      | append (resources.Get "third_party/pako/pako_deflate.min.js")      | append (resources.Get "js/plantuml.js") -}}{{ end -}}{{ if $shell -}}  {{ $jsArray = $jsArray      | append (resources.Get "js/docs-shell.js") -}}{{ end -}}{{ if $hasTabRuntime -}}  {{ $jsArray = $jsArray      | append (resources.Get "js/tabs.js") -}}{{ end -}}{{/* 同一组合的页面共享同一个包:文件名是成员清单的 md5 */ -}}{{ $bundleKey := "" -}}{{ range . }}{{ $bundleKey = printf "%s|%s" $bundleKey .Name }}{{ end -}}{{ $js := . | resources.Concat (printf "js/page-%s.js" (md5 $bundleKey)) -}}{{ if hugo.IsProduction -}}  {{ $js = $js | minify | fingerprint -}}  {{/* 生产环境:压缩 + 指纹 + SRI */ -}}{{ end -}}

三个设计决策值得品:

  • 包名是成员清单的 md5。十个页面用到同样的特性组合,就共享同一个缓存文件——按需加载的常见做法是每页一包,Oink 用内容寻址把缓存命中率捞了回来。
  • 1MB 的 ECharts 被刻意排除在拼接之外。源码注释原话:放进拼接包里,"每种标志组合都会背上一个无法缓存的兆级文件"。大文件单独指纹发布,小文件灵活组合。
  • 生产环境全资产强制 SRI(Subresource Integrity):每个 <script> 和 <link> 都带 sha256 完整性校验。静态站做到这个级别,供应链攻击面基本焊死。

5.4 双引擎搜索:Lunr 管英文,自研扫描器管中文

英文文档主题的搜索,到中文这里为什么集体失灵? 因为 Lunr 这类倒排引擎靠分词工作,而中文的词之间没有空格。Oink 的解法简单粗暴:检测到 CJK 字符,直接切换到自研的子串扫描路径。

搜索引擎的分流入口只有一行:

queryfunction (query) {return CJK.test(query) ? queryCjk(query) : queryLatin(query);},

CJK 路径是一个手工权重的多字段扫描器(节选自 search-engine.js):

// 命中位置决定分数:标题最贵,正文最便宜var textScore =  (titleAt >= 0 ? 100 : 0) +    // 标题命中:100 分  (keywordAt >= 0 ? 80 : 0) +   // 作者定义的关键词:80 分  (headingAt >= 0 ? 50 : 0) +   // 小节标题:50 分  (descAt >= 0 ? 30 : 0) +      // 页面描述:30 分  (bodyAt >= 0 ? 10 : 0);       // 正文:10 分if (!textScore) return;// 最终分 = 文本分 × 页面权重(front matter 里的 search_boost)hits.push({doc: doc,excerpt: excerpt,             // 命中处前后各截 24/56 字符做摘要score: textScore * number(doc.boost1),});

两个魔鬼细节:

  • 大小写折叠只做一次。源码注释明说:queryCjk 每敲一个键都要扫全库,如果每字符都重新 lowercase 一份文档拷贝,就是每键一次全量内存分配——所以建引擎时一次性折叠完存起来。
  • boost 的校验偏执到防 NaN:front matter 里的 search_boost 只接受有限正数,+InfNaN、负数一律打回并降级为 1.0,带 warning。

英文路径则走 Lunr:精确词 boost 100、前后通配 boost 10、再容错编辑距离 2——错拼一两个字母照样搜得到。索引本身是构建时按语言生成的 JSON(字段、摘要长度、范围全部可配),生产环境带 md5 指纹,整个搜索链路没有一个字节来自第三方

入口是一个 Ctrl+K 的命令面板:/ 进全量搜索、> 进命令模式,WASD/jk/qe 单键导航,焦点陷阱和 IME 输入法组合态都处理了——你用中文输入法打第一个拼音的瞬间,它不会把按键误当快捷键

5.5 公式在构建时渲染:数学页面的"零 JS"特权

为什么公式要等浏览器加载 KaTeX 再渲染? Hugo Extended 的二进制里已经编译进了 KaTeX,Oink 直接在构建时把公式变成成品 HTML:

{{- $opts := dict "output" "htmlAndMathml" "displayMode" $display -}}{{ with try (transform.ToMath .Inner $opts) -}}  {{ with .Err -}}    {{ warnf "KaTeX 渲染失败,公式保持原样:%s" . -}}  {{ else -}}    {{- .Value -}}                      {{/* 成品 HTML + MathML 双输出 */ -}}    {{ $.Page.Store.Set "hasMath" true -}}  {{/* 只登记 CSS 字体需求 */ -}}  {{ end -}}{{ end -}}

注意那个 hasMath 标志:数学渲染完 HTML 之后,运行时只需要 KaTeX 的 CSS 和字体文件——JS 一行都不用加载。渲染失败也不炸,公式保持原文并打 warning,这就是它"每个开关都优雅降级"哲学的一个切片。

markdown 和 RSS 输出里公式则退化为编号文本(**式 1.**),打印视图保留图表编号与交叉引用——同一份内容,多种受众多种形态。

5.6 四种输出格式:给浏览器、打印机、读者和 AI 各留一扇门

每个 HTML 页面都会声明自己对应的 Markdown 地址,而那个 .md 版本不是把 HTML 反向转换一遍,而是保留你真正写下的内容——Callout、表格、文件树、代码围栏都按各自的 Markdown 形态输出:

页面的 Markdown 源码视图

同一个 URL 后缀换成 .md,拿到的就是干净的源文档——组件退化回围栏,公式退化回 LaTeX。

llms.txt 的生成逻辑很克制:菜单里的外链(仓库、聊天室)被明确排除——源码注释说它们是"chrome, not content",列进 AI 索引只会稀释内容。给 AI 的饲料,纯度比数量重要。

5.7 质量工程:16682 行 Python 在守门

这是全项目最反常识的部分:检查器代码(1.67 万行)比模板实现(1.17 万行)还多。CI 里跑着 33 个检查器,其中最狠的是"四态 golden 比对":

用 181 页的回归夹具构建出 HTML / 打印 / Markdown / RSS / LLMS 的 40 个黄金输出面,归一化指纹哈希和机器路径后逐行 diff 锁定——任何一个输出字节变了,CI 都会让你解释。此外还有 85 项迁移测试、38 项浏览器运行时测试,外加双语站点构建、大型站点性能测量和真实中英文浏览器检查。

CI 矩阵跑 Hugo 0.160.1 和 0.164.0 两个版本,全程 --panicOnWarning——警告即失败。还有一个专门的测试:用 Hugo Module 模式构建一个消费方站点,确保"模块发布形态"和"本地主题形态"的路径解析都被覆盖。

错误策略也分了场景:hugo server 遇到错误配置尽量警告并降级,不让一个错别字搞挂整个预览;生产构建则严格panic。这套思路很像数据库系统——开发阶段给诊断,生产发布立门槛。

版本哲学同样是一景。0.6.1 的更新日志修了一个 1 像素的导航栏错位,讲清楚了 border-box 高度、50px 预留带和基线对齐的来龙去脉,写了三段论文式的分析。这种 changelog,说实话,比多数项目的 RFC 都认真。


六、典型场景

场景一:政企内网/离线环境的文档站

银行、政务、军工、工厂车间——这些环境里"文档站功能阉割"是常态,搜索砍掉、图表砍掉、公式变乱码。Oink 的产物是一个纯静态目录,扔进 nginx 或者任何文件服务器就完事,审计时每一个字节都可以本地核对。Pigsty 这种发行版软件的私有化交付文档,就是这个场景的重度用户。

场景二:写书、翻译、维护大型双语知识库

Oink 的"书籍"内容形态是为长篇内容专门做的:章节编号、图片与表格编号、公式编号、交叉引用、目录索引、整本书连续打印。Vonng 拿《设计数据密集型应用》(DDIA)这样的多语言复杂书籍当主要试验场:

Oink 的书籍形态:DDIA 中文译本页面

带编号的图、表、公式和交叉引用——这是长篇技术内容真正会遇到的问题。

多语言直接用 Hugo 原生模型,翻译文件与原文并排放置;英文、简体中文、繁体中文界面文本完整维护;中文搜索原生可用;博客形态则带作者档案、系列文章、沉浸式 Hero 长文页面。

场景三:把文档喂给 AI Agent

2026 年,文档的第二读者已经是 AI 了。Vonng 的判断更激进:Markdown 已经成了人类与 AI Agent 之间的公共协议——README、AGENTS.md、设计文档、知识库,最终都落在 Markdown 里。Oink 的内容模型从一开始就假设:这份文档既要给人看,也要给机器读。

不想从零开始?抄作业

Oink 的公开案例库收录了 15 个真实生产站点,从两页的小工具到上千份内容文件的发行版手册都有覆盖。作者推荐的最快路径不是从空目录研究配置,而是找一个最像你需求的现成站点,克隆下来,把 content/ 换成自己的

15 个公开案例站点

这些不是专门做出来的 Demo,是作者自己每天都在维护的生产站点。


七、5 分钟跑起来

前置条件只有三样:Git、Go、Hugo Extended ≥ 0.160.1。对,没有 npm install,没有 node_modules,没有 lockfile。

方式一,从零初始化:

# 1. 初始化一个站点模块hugo mod init github.com/example/docs# 2. 拉取 Oink 主题hugo mod get github.com/pgsty/oink@latest

方式二(官方推荐),直接克隆样例站改内容:

git clone https://github.com/pgsty/oink.pgsty.com my-docscd my-docshugo server

然后在 hugo.yaml 里启用它(关键部分):

module:imports:path: github.com/pgsty/oinkoutputs:home: [HTMLRSSmarkdownLLMS]   # 按需开启四种输出page: [HTMLmarkdown]section: [HTMLRSSprintmarkdown]params:offline_search: true# 本地搜索,构建时生成索引ui:image_zoom: true# 图片点击放大dark_mode:show_menu: true# 暗色模式切换菜单

本地预览:

hugo server# 大型站点想跳过编辑循环里的索引构建:HUGOxPARAMSxOFFLINE_SEARCH_ON_SERVE=false hugo server

部署更是无聊:hugo 一条命令,把 public/ 目录扔上任何静态托管——GitHub Pages、Cloudflare Pages、对象存储、内网 nginx,都行。

从 Docsy 迁移的用户,仓库里带了 bin/check-site-markup.py 迁移体检脚本和 85 项配套迁移测试,先体检再动手。甚至可以按作者的建议,直接把建站任务整段扔给 Claude Code 或 Codex——文档站源码本身就是完整样例,Agent 照着抄就行:

把建站任务交给 Agent 的提示词

作者给出的原始提示词:描述你想要的网站,Agent 负责克隆样例、改配置、换内容。你只负责两件事——告诉它你要什么,以及把真正有价值的内容写出来。


八、谁不适合 Oink?

公平起见,把丑话说在前面:

1. 你要的是营销官网,不是文档。 Oink 是内容优先的框架,Landing 能力只是内容壳的延伸。复杂产品官网请左转 Astro / Next.js。

2. 你的文档要嵌 React/Vue 交互组件。 它坚持零框架原生 JS、构建时渲染、原生 Markdown 优先,MDX 那种"文档里写组件"的玩法是它刻意排斥的方向——用 Docusaurus 或 Fumadocs。

3. 你要的是在线协作 Wiki。 这是 Git 工作流的静态站,没有拖拽 CMS、没有多人在线编辑后台、没有账号权限系统。它的边界很清楚:以 Markdown 和结构化数据为源,构建静态内容网站。

4. 你需要一个"稳"字当头的成熟生态。 这是它目前最大的短板:项目只有两周大,0.x 版本,单一维护者。API 会动、配置键会改(它自己的迁移检查器就是干这个的)、bus factor 是 1。生产使用务必 pin 死版本号,并且接受"升级要读 changelog"的代价。

5. 你的团队完全不在乎工具链重量。 如果站点永远跑在外网、团队养得起前端工程师、也没有内网合规需求——那 Docusaurus 的生态红利确实更香。


九、结语

回到开头那个周五下午。用 Oink 重来一遍:hugo 一敲,产物里 mermaid、搜索、公式、字体全部自包含,防火墙随便拦,Algolia 是谁?然后你把 llms.txt 的链接甩给正在写运维 Agent 的同事——他不用写任何爬虫和清洗逻辑

Web 开发这十几年,钟摆一直在"瘦客户端"和"胖客户端"之间荡。Oink 代表的是一次干脆的反向压缩:把前端工程化十五年攒下的运行时,尽量塞回构建器的一个二进制里——图表构建时渲染、公式构建时渲染、搜索索引构建时生成,浏览器只拿走它真正需要交互的那部分。

而在钟摆的另一头,它还给下一个时代的读者——AI——预留了整整齐齐的餐桌。人类看 HTML,机器读 Markdown,一本内容两种形态。

这个项目当然还年轻,但一个人十三天写出 11 个版本、测试代码比实现代码还多、敢把 18 个生产网站全部押上去——这种工程习惯比任何宣传语都值得信任

用作者的话收尾:把 Markdown 喂进去,剩下的,交给这头猪。


地址:https://github.com/pgsty/oink

官方文档:https://oink.pgsty.com

案例库:https://oink.pgsty.com/case/