资讯动态

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

发布时间:2026/10/28 3:50:13 来源:尧图企业网站定制
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.yamldocs是内容目录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 也是允许的。如果没有 titlePonytail 会直接使用文件名作为标题。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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{{title}} - Ponytail/title style{{css}}/style /head body aside classtoc{{toc}}/aside main classcontent{{content}}/main script{{js}}/script /body /html注意{{css}}和{{js}}这两个变量会直接被替换成style和script包裹的完整内容所以模板里不需要自己写标签。这是我在设计时故意做的一点简化避免用户重复粘贴标签导致内容被嵌套两次。换肤也很方便。我在默认 CSS 里把所有颜色都提取成了 CSS 变量集中在文件头部。改深色主题时只需要把背景色、文字色、链接色、边框色统一替换两分钟就能完成。4. 进阶功能与扩展4.1 全文搜索把索引也装进单文件很多人觉得单文件站点做不了全文搜索因为无法请求服务端。Ponytail 的方案是构建时把每篇文章的正文做简单分词去掉停顿词后生成一个 JSON 索引直接内联到 HTML 的script typeapplication/json idsearch-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}}}会被当成普通文本输出。再看是不是在模板里写了两行相同的变量名后者覆盖了前者。最后检查一下模板文件保存时是不是带了 BOMBOM 有时会导致前面的变量替换失败。我在排查建议里加了一条写模板时尽量一次只改一个变量构建后立刻查看输出。因为模板解析错误会直接导致整个页面渲染中断不像 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而是一条干净利落的马尾辫。这个项目后续不会长成大怪物但我会一直用它、维护它。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑