按下发布以后:一篇 Markdown 到达浏览器的完整旅程
在 Pages CMS 里写完文章,点一下保存,过一会儿刷新网站,新文章就出现了。
这个过程看起来很像把一张照片发到社交平台:选择、确认、等待几秒,然后结束。但个人博客背后没有一个长期运行的后台替我处理请求。服务器上也没有 WordPress 和数据库。真正被发布出去的,只是一批 HTML、CSS、JavaScript、字体和图片文件。
那么,从一个 Markdown 文件到读者屏幕上的文字,中间究竟发生了什么?
这一次我不再写一份搭建步骤,而是沿着金猪博客当前正在使用的发布链路,从头走一遍。途中会经过 Hexo、Keep 主题、Git、GitHub Actions、Cloudflare Pages、DNS、CDN,最后才轮到浏览器。
起点并不是网页
博客文章最初只是 source/_posts 目录中的一个文本文件。文件上方是 YAML Front Matter,下面才是正文:
1 |
|
Front Matter 看起来只是几行配置,实际上决定了这篇文章会不会出现、排在什么位置、使用什么地址,以及怎样进入分类页、标签页、站点地图和搜索索引。
例如,本站配置了:
1 | permalink: :year/:month/:day/:title/ |
因此,这篇文件最终对应的地址是:
1 | /2026/07/31/markdown-to-browser-publishing-journey/ |
如果发布时间晚于当前时间,Hexo 会把它当成未来文章,不生成页面。此前我就遇到过文章已经写好、构建也没有报错,但首页上始终找不到的情况。原因不是部署失败,只是时间比构建机器快了十几分钟。
这说明发布链路的第一层并不是网络,而是内容模型。标题、日期、时区和 published 等字段只要有一个不符合预期,后面的系统即使全部正常,也只会忠实地发布一个“不包含这篇文章”的网站。
Hexo 先把内容变成数据
执行 hexo generate 后,Hexo 并不会立刻逐个复制文件。它会先读取站点配置、加载插件和主题,再解析文章及页面,把它们放进一套内部数据模型。
一篇文章在这时不再只是一段 Markdown,而是一个包含标题、正文、日期、永久链接、分类、标签等信息的对象。归档生成器可以按年份读取它,标签生成器可以把它放进相应的标签页,首页生成器则会按照日期选出最近的文章。
这也是静态站点生成器与普通“Markdown 转 HTML”工具的重要区别:后者只处理一份文本,前者需要理解整个网站。
Renderer 负责翻译,Generator 负责修路
Hexo 的构建过程里有两个容易混淆的角色。
**Renderer(渲染器)**负责把一种文件内容转换成另一种内容,例如:
1 | Markdown → HTML |
本站并没有完全使用默认 Markdown 渲染流程,而是在 scripts/katex-renderer.js 中注册了基于 Markdown-it 的渲染器,同时处理 KaTeX 数学公式。此前公式显示异常,问题就发生在这一层:Markdown 本身没有丢失,但渲染器没有把公式分隔符正确变成 KaTeX HTML。
**Generator(生成器)**处理的是“网站应该有哪些地址”。它接收所有文章、页面、分类和标签,再创建路由:
1 | index.html |
Hexo官方也把二者明确分开:Renderer 处理内容转换,Generator 根据站点数据建立路由。理解这一点以后,排错会清楚很多:
- 正文中的 Markdown 没有正确显示,先查 Renderer;
- 文章存在但没有分类页,先查 Generator 或 Front Matter;
- 页面结构正确但样式不对,继续向主题和 CSS 查。
Keep 主题把内容放进页面
渲染后的正文仍然不是完整网页。它没有导航栏、页脚、文章目录、版权信息,也没有 <head> 中的 SEO 元数据。
Keep 主题会把正文放入文章模板,再把文章模板放进整页布局。主题中的 EJS 模板决定 HTML 结构,Stylus 文件经过渲染后形成最终的 css/style.css。
可以把这个过程理解成三层:
1 | 文章内容 |
以前出现过一次很离谱的故障:本应参与编译的 Stylus 源码直接出现在页面上,盖住了正文。浏览器没有能力理解 Stylus 变量,自然只能把那段内容当作普通文字。那次事故看起来像“整个主题都坏了”,实际故障范围仍然在渲染与模板交界处。
为了防止同类问题再次混进部署产物,本站现在会专门检查文章工具栏中是否出现 $post-tool-button-width、+keep-tablet() 等原始 Stylus 特征。
public 才是真正准备上线的网站
所有内容与模板处理完毕后,Hexo 把结果写入 public 目录。
在写这篇文章前,我对当前站点做了一次快照:
| 项目 | 数量或体积 |
|---|---|
| 文章 | 20 篇 |
| HTML 页面 | 90 个 |
| 全部静态文件 | 235 个 |
| 总体积 | 约 5.74 MiB |
| CSS | 约 324 KiB |
| JavaScript | 约 119 KiB |
文章只有 20 篇,为什么会生成 90 个 HTML?
因为首页、分页、归档、年份归档、分类、每个标签、About、隐私页面和 404 页面都需要自己的 HTML。静态网站所谓的“提前生成”,就是在部署前把这些可能被访问的页面全部算好。
此时即使断开 Node.js,甚至把 Hexo 从电脑中删除,public 中的网站仍然可以工作。Hexo只参与生产,不参与访问。读者打开文章时,Cloudflare不会临时运行 Hexo,也不会现场解析 Markdown。
构建通过不等于可以发布
本站的 npm run build 最终调用 hexo generate,但外面又包了一层检查:如果 Hexo 虽然以成功状态退出,日志里却出现内部渲染错误,构建脚本仍会主动失败。
生成结束后,npm run check 会遍历整个 public,检查:
- 每页是否有唯一的标题、摘要与 canonical URL;
- 文章摘要是否重复或长度异常;
- JSON-LD 是否为合法数据;
- 图片是否带有替代文本;
- 本地图片、脚本和链接是否真实存在;
- 标签页与分类页是否为空;
- 数学公式是否仍含解析错误;
- CSS 是否小得异常;
- 主题工具栏是否混入原始 Stylus;
robots.txt、站点地图、RSS 和安全文件是否生成。
这些检查不保证文章内容一定优秀,但能阻止一批“构建成功、网站却坏了”的情况。
这里有一个很重要的区别:
1 | Hexo 成功:文件生成出来了 |
只有两者都通过,发布流程才会继续。
source 与 main 是两种完全不同的东西
这个仓库保留了两个主要分支:
source保存 Markdown、配置、主题覆盖、构建脚本和工作流;main保存已经生成的静态网站。
Pages CMS 修改文章后,提交进入 source。GitHub接收到新的提交,触发 deploy-hexo.yml:
1 | on: |
工作流在一台临时的 Ubuntu 虚拟机中依次执行:
1 | 检出 source |
使用 npm ci 而不是随手执行 npm install,是为了严格按照锁文件安装依赖。构建机器用完即销毁,因此不能依赖我电脑里“恰好存在”的某个文件。
最后一步使用 rsync --delete 同步文件。这意味着新构建里已经删除的页面,也会从 main 中删除,避免旧文章或旧资源永久残留。同步结果形成一个新的 Git 提交,再一次性推送。
如果构建或检查失败,发布步骤不会运行,main 仍停留在上一个可用提交。这比直接在生产分支上边改边生成更容易恢复。
工作流还设置了:
1 | concurrency: |
如果短时间内连续保存两次文章,较新的任务会取代仍在运行的旧任务。否则旧构建可能比新构建更晚完成,把刚发布的内容覆盖回去。
Cloudflare Pages 接过最后一棒
main 更新以后,Cloudflare Pages取得新的静态文件并创建部署。自定义域名 dajinzhu.cn 指向这个 Pages 项目,Cloudflare同时处理 TLS 和边缘分发。
读者访问:
1 | https://dajinzhu.cn/2026/07/31/markdown-to-browser-publishing-journey/ |
Pages会按照路径寻找对应文件,最终返回:
1 | 2026/07/31/markdown-to-browser-publishing-journey/index.html |
这就是地址末尾没有 index.html,网站却仍能返回页面的原因。URL是给人和搜索引擎看的路径,存储层仍然可以是一个普通文件。
Cloudflare Pages的部署不是让所有读者直接访问 GitHub。GitHub在这里负责保存与触发,Cloudflare才是实际向访客交付文件的一方。
缓存并不是一个开关
“清一下缓存”经常被当成万能答案,但一次页面访问里至少可能遇到三处缓存:
- 浏览器缓存;
- Cloudflare边缘节点缓存;
- Pages部署对应的静态资源版本。
我在 2026 年 7 月 31 日读取了本站公开响应头。当时首页返回:
1 | 200 OK |
而 css/style.css 返回:
1 | 200 OK |
两者策略不同是有意为之。
HTML包含文章列表和资源地址,更新后应尽快重新验证;CSS、JavaScript、字体和图片变化较少,可以在浏览器中保存更久。ETag相当于某一版本资源的标识。浏览器下次可以携带 If-None-Match 询问服务器:我手里的版本还是不是最新的?如果一致,服务器返回 304 Not Modified,不必重新传输文件正文。
但本站目前的 style.css 没有把内容哈希写进文件名。如果浏览器仍认为一天内的旧 CSS 可用,新 HTML又依赖刚修改的样式,就可能短暂出现“结构是新的,样式还是旧的”。缓存并没有弄错,它只是严格执行了我们给出的规则。
长期看,更稳妥的资源策略是:
1 | HTML:每次重新验证 |
这比每次出问题都让所有人强制刷新可靠得多。
浏览器拿到 HTML 后,工作才完成一半
HTTP返回 200 OK 并不代表页面已经显示。
浏览器会解析 HTML,建立 DOM;解析样式表,建立 CSSOM;再把两者组合成渲染树,计算每个元素的位置与尺寸,最后绘制成屏幕上的像素。外部 CSS通常会阻塞首次渲染,因为浏览器必须先知道样式,才能确定页面应该长什么样。
与此同时,HTML中的引用会产生新的请求:
1 | HTML |
因此,“文章页面能打开”和“文章完整可读”也是两件事。HTML返回正常但 CSS 失败,得到的是无样式页面;字体失败,排版会发生变化;图片地址错误,正文会留下空白;脚本错误,搜索、目录或懒加载可能失效。
此前猪头被拉得很长,就是浏览器布局阶段暴露出来的问题。图片文件本身没有坏,网络请求也成功了,但 CSS 给它的尺寸约束不正确。故障发生在旅程的最后几步,却会让人误以为整个部署都坏了。
同一个故障,在不同层看起来完全不同
排查静态网站时,我现在更愿意先判断故障属于哪一层:
| 层级 | 常见现象 | 优先检查 |
|---|---|---|
| 内容 | 文章缺失、日期错误、分类为空 | Front Matter、时区、published |
| 渲染 | 公式错误、源码出现在正文 | Markdown渲染器、主题模板、过滤器 |
| 生成 | 标签页、归档或资源未产生 | Generator、Hexo配置、public |
| CI | CMS已保存但网站没有变化 | source提交、Actions日志 |
| 发布 | Actions成功但生产内容仍旧 | main提交、Pages部署 |
| 缓存 | 部分人正常、部分人仍见旧样式 | 响应头、ETag、浏览器缓存 |
| 浏览器 | 图片变形、布局跳动、功能失效 | CSS约束、资源请求、控制台 |
这种分层比反复执行“清理、生成、部署”更有效。三件套只能重新跑完整流程,不能告诉我问题发生在哪里。
我怎样确认一篇文章真的发布了
现在,一次完整发布至少要经过四个证据点:
source中存在包含文章的提交;- GitHub Actions 的构建与检查全部通过;
main中存在生成后的index.html;- 公开地址返回正确标题、摘要和资源引用。
少任何一个,都不能简单地说“已经上线”。
例如,Actions显示绿色,只能证明工作流按定义成功。如果工作流同步了错误目录,它仍然可以绿色;main 已有文件,也不等于自定义域名已经切换到这一版;HTML能返回,也不等于图片和 CSS 完整。
真正可靠的发布状态不是一个图标,而是一条可以逐段验证的证据链。
静态网站并不简单,只是把复杂度搬到了发布之前
WordPress一类动态网站会在访问时查询数据库、执行程序并组装页面。Hexo则把大部分工作提前到构建阶段。读者访问时只需要取得静态文件,因此运行时更轻,也更容易缓存。
但复杂度没有消失。它从“每次请求如何生成页面”,转移成了:
- 内容怎样建模;
- 模板怎样渲染;
- 构建怎样保持可重复;
- 产物怎样验证;
- 部署怎样避免相互覆盖;
- 缓存怎样兼顾速度与更新;
- 浏览器怎样稳定地完成布局。
按下发布以后,一篇 Markdown 并没有直接飞到读者手机里。它先被解析成数据,渲染成正文,装进主题,扩展成整套网站,经过自动检查,进入生产分支,再由 Cloudflare分发到网络边缘。最后,浏览器重新把那些静态文件组合成我们看到的页面。
整个过程绕了一大圈,终点却仍然只是几行文字。
这也是我喜欢静态博客的原因之一:每一层都可以拆开看,每一步都有文件、有日志,也有办法验证。网站偶尔还是会坏,但至少下一次再面对一个被拉长的猪头时,我知道应该从旅程的哪一站开始找。