ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Ponytail:用 Go 打造 Markdown 转单文件 HTML 的离线文档工具

Ponytail:用 Go 打造 Markdown 转单文件 HTML 的离线文档工具

1. 项目概述

项目名字叫 Ponytail,听起来像某个发型,实际上是一个我平时用来解决"文档分享"这个老大难问题的小工具。

做这个项目的起因特别简单:我在本地写了很多 Markdown 笔记和接口说明,每次想发给同事或者朋友,对方要么得装一个 Markdown 阅读器,要么我只能把内容复制到聊天框里,格式全丢。后来我把这些笔记整理出来,用静态站点生成器打包成网站,可是发出去又得部署、又得维护域名,临时发给客户看一个报价说明,搞这么重实在不值。所以我就想,能不能把一堆散落的 Markdown 文件"扎成一束",直接生成一个单独的 HTML 文件,样式、目录、代码高亮全部打包好,谁拿到这个文件,双击就能看,不需要服务器,不需要安装任何东西。这就是 Ponytail 的诞生。

Ponytail 最有价值的点不是"又一个静态站点生成器",而是它把输出目标锁定为单文件。这种模式特别适合个人项目主页、技术文档、课程讲义、菜谱手册、临时给客户看的方案说明,甚至可以直接放进 U 盘带走。整个项目用 Go 语言开发,编译完只有一个二进制文件,没有任何运行时依赖,下载下来就能跑。这篇文章我会从设计思路、技术实现、实操配置到排坑经验都讲一遍,想直接抄作业的朋友看完就能上手。

2. 核心设计与技术选型

2.1 整体架构:从文件夹到单页面的转换链路

Ponytail 的整个处理链路可以分成四步:扫描、解析、渲染、打包。命令行跑起来之后,它先递归读取你指定的输入目录,把 Markdown 文件按目录结构组织成一颗文档树;接着用解析器抽取每个文件头部的元数据,也就是 title、date、tags 这些字段;然后调用渲染器把 Markdown 正文转换成带层级的 HTML,并生成对应的目录树;最后进入打包器,把渲染结果、CSS、JavaScript、图片资源统一内联到一个 HTML 文件里,输出到指定位置。

这里最核心的决策是单文件输出。为什么不做成多页面 HTML?因为多页面意味着你要做相对路径处理、目录层级映射、资源拷贝,还要依赖一个 Web 服务器来处理跳转。单文件模式下,所有页面内容其实都塞进了一个 HTML,用少量 JavaScript 控制显示哪个章节。这样用户拿到手就是一个文件,拖到浏览器里就能看,甚至可以用微信或者邮件直接发给别人。

技术选型上,Markdown 解析我用的是 Go 生态里的 goldmark,它对 CommonMark 规范支持很完整,而且扩展机制很干净,代码块、表格、删除线这些都能原生处理。代码高亮用的 Chroma,是 Hugo 同款的高亮引擎,支持两百多种语言。模板渲染没有上 mustache 或者 handlebars,直接用 Go 标准库的 text/template 封装了一层极简模板语法,因为需要暴露给用户的变量就那几个,搞太多抽象反而让使用者困惑。

2.2 单文件打包的几个关键决策

既然目标是要把整个站打进一个 HTML,那就必须解决"资源内联"的问题。CSS 和 JavaScript 最好处理,直接把文件内容读出来,分别塞进<style>和<script>标签就行。图片稍微麻烦一点,因为图片是二进制格式,我的处理方案是把小于配置阈值的图片转成 base64 编码,写进 HTML。构建时给用户提供--inline-limit参数,默认是 10KB,超过这个大小的图片保留外部链接,这样避免一个 5MB 的截图把整个 HTML 撑爆。

内联资源有个很容易踩的坑:如果 JavaScript 文件里出现了字符串</script>,浏览器会提前结束 script 标签,导致页面渲染错乱。首次实现的时候就被这个坑坑过一次。后来我在打包器里加了转义逻辑,把<转成\u003c,也就是把</script>输出成<\/script>,这样浏览器执行时得到的还是原始的<script>字符串,页面却不会中断。

还有一个决策是构建产物要不要压缩。我默认做了"轻量压缩",压缩掉多余空白和注释,但不会做太过激的混淆。原因是单文件本来就要保证可读性,用户如果拿到一个 HTML 想改点样式,压缩得太狠反而没法定位。命令行里也留了--minify开关,需要极致缩减体积时可以手动打开。

2.3 模板系统的取舍:够用,但别过度设计

我见过很多静态站点项目,一上来就引入 mustache、handlebars、liquid 等模板引擎,配置复杂,语法学习成本高。Ponytail 的定位是"一个小项目",模板需求其实就两个:允许用户自定义整体 HTML 骨架,允许在骨架里插入几块核心内容。

所以我定义了一套极度简化的模板变量,支持{{title}}、{{content}}、{{toc}}、{{meta}}、{{css}}和{{js}}这几个占位符。默认主题只有不到 200 行 HTML,用户可以复制到自定义模板目录里,替换橙色的 CSS 变量、调整导航栏位置,不需要学习任何复杂语法。

为什么不用 Go 原生的{{.Title}}这套写法?因为原生模板对非程序员用户来说不够友好,而且绑定数据模型会限制灵活性。我用一层很薄的解析器,先把用户模板里的{{title}}替换成编译后的实际内容,再交给 text/template 做剩余处理。这样既保留了扩展空间,又让模板看起来像一个普通的 HTML 文件,没有任何心智负担。

3. 实操:从安装到生成第一个站点

3.1 安装与初始化

Ponytail 的目标是"拿到就能跑",所以安装过程必须极简。如果你用 macOS 且装了 Homebrew,可以直接执行:

brew install ponytail

没有 Homebrew 或者不是 macOS 也没关系,我从 GitHub Releases 里放出各个平台的压缩包,下载后解压就是一个可执行文件。Linux 服务器上可以这样装:

wget https://example.com/releases/ponytail_linux_amd64.zip unzip ponytail_linux_amd64.zip sudo mv ponytail /usr/local/bin/

装完之后,可以先用ponytail init生成一个示例项目骨架:

ponytail init ./mysite

执行完你会看到这样的目录结构:

mysite/ ├── docs/ │ ├── index.md │ └── posts/ │ └── hello.md ├── assets/ │ └── css/ │ └── style.css ├── templates/ │ └── default.html └── ponytail.yaml

docs是内容目录,assets放样式和脚本,templates放自定义外壳,配置文件保存构建参数。这整套结构都是为了"少折腾"设计的,能在一个目录里完成整个项目。

3.2 内容目录与 Front Matter 约定

和多数静态站点生成器一样,Ponytail 使用 Markdown 文件开头的 Front Matter 来记录文章的元信息。每一篇文档可以像下面这样写:

--- title: 你好,Ponytail date: 2024-06-01 tags: [example, tutorial] slug: hello-ponytail --- 这里是正文内容。

解析器只认---分割的 YAML 块,不写 Front Matter 也是允许的。如果没有 title,Ponytail 会直接使用文件名作为标题。slug字段是文章在目录锚点中使用的短链接,这个字段建议长期保持稳定,因为目录跳转依赖它。

文档树的排序逻辑是:先按数字前缀排序,没有数字前缀的按文件名字母排序。如果docs目录下还有子目录,每个子目录会生成一个分组,分组标题默认取目录名,也可以在目录里放一个_index.md来覆盖分组标题和描述。

3.3 构建命令与参数解析

初始化完成后,在项目根目录执行:

ponytail build

构建成功后,输出文件默认生成在dist/index.html,控制台会显示文件体积和构建耗时。常见全局参数如下:

ponytail build \ -s ./docs \ -o ./dist/site.html \ -c ./ponytail.yaml \ --title "我的文档" \ --lang zh-CN \ --inline-limit 10240 \ --minify

-s指定内容目录,-o指定输出文件,-c指定配置文件。标题和语言如果不指定,会从配置文件里读取;命令行参数优先级更高,这样在做自动化构建时可以临时覆盖标题,不需要反复改配置文件。

--inline-limit前面提过,是控制图片是否转 base64 的阈值。--minify打开后会对最终 HTML 做压缩,体积大约能再缩小 15% 到 25%。如果你是拿去公开发布,建议开启--minify;如果是本地留着改样式,保持默认关闭就好。

3.4 自定义主题:模板语法与样式替换

默认模板输出一个带左侧目录、右侧正文的经典文档布局。想换皮肤,不用重新编译程序,直接修改templates/default.html和assets/css/style.css即可。

自定义模板的例子:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{title}} - Ponytail</title> <style>{{css}}</style> </head> <body> <aside class="toc">{{toc}}</aside> <main class="content">{{content}}</main> <script>{{js}}</script> </body> </html>

注意{{css}}和{{js}}这两个变量会直接被替换成<style>和<script>包裹的完整内容,所以模板里不需要自己写标签。这是我在设计时故意做的一点简化,避免用户重复粘贴标签导致内容被嵌套两次。

换肤也很方便。我在默认 CSS 里把所有颜色都提取成了 CSS 变量,集中在文件头部。改深色主题时,只需要把背景色、文字色、链接色、边框色统一替换,两分钟就能完成。

4. 进阶功能与扩展

4.1 全文搜索:把索引也装进单文件

很多人觉得单文件站点做不了全文搜索,因为无法请求服务端。Ponytail 的方案是构建时把每篇文章的正文做简单分词,去掉停顿词后,生成一个 JSON 索引,直接内联到 HTML 的<script type="application/json" id="search-index">里。前端搜索框通过原生 JavaScript 做关键词匹配,结果按匹配次数排序,展示命中文章的标题和摘要片段。

这套方案对几百篇文章的小型项目完全够用。我测试过一个 200 篇文章、约 2MB 的文档站,全文索引大概是 200KB,嵌入 HTML 后浏览器解析不到 100 毫秒。“搜索”按钮点开,输入关键词,响应时间在 100 毫秒以内。虽然比不了 Elasticsearch,但满足"离线可搜"这个需求,已经很香了。

在构建命令里,搜索索引默认生成,如果你不想暴露全部内容,可以加--no-search-index关闭。这个开关在生成对外交付的脱敏文档时很有用,避免把内部专业术语全部带出去。

4.2 自动化部署:提交代码自动生成单文件

因为输出只有一个 HTML,部署变得异常简单。我推荐的做法是:写一个 GitHub Actions workflow,在每次推送 main 分支时执行ponytail build --minify,然后把dist/index.html上传到 GitHub Pages 或者阿里云 OSS、腾讯云 COS 这类对象存储。

对象存储的逻辑就是"上传一个文件",不需要同步整个目录,也不需要处理缓存失效策略。我搭建的发布流程大概这样:

- checkout 代码 - 下载对应平台的 ponytail 二进制 - 执行 ponytail build -c ponytail.yaml --minify - 用 ossutil 上传 dist/index.html 到存储桶

这样每次写完文档,推一次代码,线上站点就自动更新了。给别人发文档的时候,直接发 OSS 的临时链接,或者把 HTML 文件从 Dist 目录拖出来发给对方,都行。

4.3 和主流静态生成器的对比

总有人问我,已经有 Hugo、Jekyll、VuePress 了,为什么还要用 Ponytail 这种小工具?我的回答很简单:它们解决的问题不同。

项目PonytailHugoVuePress
安装依赖单个二进制单个二进制Node.js 环境
输出形式单 HTML 文件多页面静态站多页面静态站
配置复杂度极简 YAML中高,主题系统庞大中高,插件体系丰富
适合场景临时文档、离线分享大型内容站、博客技术文档、组件库文档
学习成本半小时上手半天到一周一天到三天

Ponytail 不适合做追求极致 SEO、需要多语言且动态性很强的大型内容站点;它适合的是"Quick and Dirty"的场景。我经常拿它做客户方案,生成单文件发给客户,客户在浏览器里打开,排版清新、目录明确,也不需要线上环境,体验很好。

5. 常见问题与排查实录

5.1 文件名带中文,生成后图片和链接打不开

很多人建立一个docs/我的笔记/图片.jpg路径的站点,构建时没有报错,但点击链接却 404。原因是浏览器对中文路径需要编码,而早期版本我在生成链接时没有做 URL 编码,导致href="/我的笔记/图片.jpg"无法被正常识别。

解决方案有两个:一是给目录和文件起英文名,内容可以有中文标题,但路径和文件名保持 ASCII 字符;二是在解析器里对链接自动调用url.PathEscape编码,这个逻辑我在后续版本已经加入了。如果你使用的是旧版本,出现类似问题,优先检查走的是不是中文路径。

5.2 图片太多导致 HTML 体积爆炸

用户反馈过一个问题:文档里放了 50 张 2MB 的截图,最后生成的 HTML 有 100 多 MB,浏览器直接卡死。

这里要明确一点:内联图片确实会带来便利,但 base64 会让图片体积膨胀约 33%。所以我在构建时做了阈值判断,默认超过 10KB 就不内联。如果你的文档截图动辄几 MB,建议先把截图压缩到宽 1200px 以内、质量 80% 左右,再交给 Ponytail。也可以直接设置--inline-limit 0,让所有图片走外部相对路径,单文件体积会小很多,只是分享时需要把图片文件夹一起带走,就不算严格意义的"单文件"了。

5.3 中文标题生成的锚点跳转失效

点击左侧目录里的"三、接口说明",页面没有滚动到对应位置,或者跳到了文章开头。原因是 Markdown 生成的标题 ID 在包含中文时,浏览器默认不会自动支持所有字符的锚点定位。

我的处理方式是:在目录和标题生成时,统一使用slugify函数,把中文标题转换成带连字符的拼音缩写加数字序号。但拼音缩写对用户不友好,所以更推荐的做法是给文章加slug字段,自定义这篇文档的短链接,这样锚点就是稳定的英文 ID。如果不想自定义,也可以使用最新版本的默认逻辑:标题 ID 使用 UTF-8 编码后的字符串,同时用 JavaScript 解码后做 scrollIntoView,中文也能正常跳转。

5.4 模板变量没生效,页面出现未替换的花括号

这个问题的排查往往是小细节。先看是不是用了两层花括号:Ponytail 的模板变量是{{title}},如果写成了{{{title}}},会被当成普通文本输出。再看是不是在模板里写了两行相同的变量名,后者覆盖了前者。最后检查一下模板文件保存时是不是带了 BOM,BOM 有时会导致前面的变量替换失败。

我在排查建议里加了一条:写模板时尽量一次只改一个变量,构建后立刻查看输出。因为模板解析错误会直接导致整个页面渲染中断,不像 JS 报错还能看到半边页面,这个问题容易让人无从下手。

6. 实操心得与扩展思路

6.1 我踩过的一些坑,提前告诉你

第一个要提醒的是内联 JavaScript 中的转义问题。如果你在模板里自定义了一段代码,内部包含</script>字符串,一定要写成<\/script>。这个 bug 很隐蔽,浏览器会直接截断脚本,页面上的目录和搜索会全部失效,控制台也不会报特别明确的错误。

第二个坑和 Go 的 text/template 有关。我在自定义模板里塞了一段 HTML 注释,里面带了{{和}}的示例代码,结果解析器直接把注释里的内容当成了变量。后来我规定所有模板里如果要展示花括号示例,必须用字符实体&#123;和&#125;代替。

第三个是生成时间戳。有次我把同一个 HTML 发给三个客户,后来发现其中一份内容改过,但文件名都是index.html,根本没法区分版本。现在的做法是在生成文件底部嵌入一个不可见的版本块,包含生成时间和 git commit 短 SHA。这样自测或客户反馈问题时,能立刻知道对方拿到的是哪个版本。

第四个是文件权限问题。某些企业环境里,双击 HTML 会被安全软件拦截,提示"不允许直接执行本地脚本"。如果遇到这种情况,用python3 -m http.server起一个本地服务,把 HTML 作为站点打开即可。Ponytail 生成的页面在 HTTP 服务和本地 file 协议下都能正常工作,所以这只是环境策略问题,不是工具 bug。

6.2 Ponytail 后续可以怎么扩展

目前版本我已经用了一段时间,满足日常所需,但依然有几个可以扩展的方向。

第一个方向是输出成 PDF。单文件 HTML 配合无头浏览器,一行命令就能转成 PDF,非常适合生成课程讲义、方案白皮书。第二个方向是增加"多语言"支持,给每个页面生成语言切换入口,这样国际项目组就不用自己维护两套文档了。第三个方向是做一个主题市场,让用户上传模板、共享配色,避免每个人都要从零开始写 CSS。

其实我从 Ponytail 得到的最大体会是:作者不一定要做出什么惊天动地的平台,能把自己平时最烦的事情解决掉,并且解决得足够干脆,这就是好工具。我现在处理任何文档类分享,第一反应就是打开终端,执行ponytail build,然后拖出那个单文件发出去。文档不再是一堆散不见尾的 Markdown,而是一条干净利落的马尾辫。这个项目后续不会长成大怪物,但我会一直用它、维护它。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进