把 Hexo 文章文件名、URL 和排序分开管理
这次整理博客,起因是在 source/_posts 里越来越难找到文章。
文章少的时候,把文件名直接写成公开 URL 的标题段很自然。比如:
source/_posts/static-site-cache-control-oss.md
source/_posts/mobile-webview-remote-debugging-android-ios.md
看到文件名,就能猜到文章 URL。这个关系很直接,也很符合 Hexo 默认习惯。
问题出现在文章变多以后。文件列表按字典序排,新文章不一定出现在列表头部,而是插到某个英文标题段的位置;平时想找的又往往是中文标题,比如「静态站点缓存怎么配」「移动 WebView 远程调试」,不是 static-site-cache-control-oss 这类 URL 片段。文件名服务 URL,反而让本地维护变慢。
最先想到的是两条路:给文件名前面加数字,或者直接把文件名换成中文标题。但这两条都不能影响现有 URL。公开地址已经发出去以后,文件系统怎么整理,应该是本地维护问题;URL 怎么保持稳定,是另一件事。
文件名、URL 和列表排序各自负责什么
本地查找、公开地址和页面排序分别交给下面三个来源:
| 目标 | 负责对象 |
|---|---|
| 本地文件好找、好扫读 | 文件名 |
| 公开 URL 长期稳定 | frontmatter 里的 url_title |
| 列表页顺序稳定 | Hexo 和主题配置 |
文件名因此可以按本地查找习惯组织:先按日期聚合,再放中文标题,不必继续沿用公开 URL 的标题段。
数字前缀没有采用。它能表达先后顺序,但会马上遇到 1 后面跟着 10、11 的字典序问题;加前导零又会在数量级变化时重新折腾。日期前缀更自然。它本来就是文章的公开路径组成部分,也正好提供了一个命名空间:同一个文件夹不能有同名文件,但 Hexo URL 带日期,不同日期可以出现相同标题段。
文件名最终变成:
source/_posts/YYYY-MM-DD - 中文标题.md
日期和标题之间用「空格、横杠、空格」分隔,看文件列表时更容易辨认。
比如现在的文件会长这样:
source/_posts/2026-06-02 - 静态站点缓存怎么配:从 Hexo 全站协商到 Astro 的 hash 资源.md
source/_posts/2026-06-05 - iOS WebKit 中 transform 和 z-index 的一次层级闪烁排查.md
对应的 Hexo 配置也要改成同样的形态:
new_post_name: :year-:month-:day - :title.md
use_slug_as_post_title: true
这一步不只是为了新文章。Hexo 处理旧文章时也会用 new_post_name 解析文件名里的日期和标题。如果文件名已经改成 YYYY-MM-DD - 中文标题.md,配置仍然停在 :year-:month-:day-:title.md,日期和标题就会解析错。
URL 标题段交给 url_title
文件名可以改,公开 URL 不能跟着变。
所以 permalink 不再使用 Hexo 默认意义上的 :title,而是改成一个明确字段:
permalink: :year/:month/:day/:url_title/
每篇文章只需要声明这个 URL 标题段:
---
url_title: static-site-cache-control-oss
---
例如,下面这个公开地址会继续保留:
/2026/06/02/static-site-cache-control-oss/
文件名改成中文以后,URL 仍然不变。
这里的文章 URL 带日期,所以不同日期可以使用相同标题段;资源 URL 却只有 /files/<url_title>/,不带日期。本博客因此仍保持 url_title 全站唯一,避免两篇文章的资源映射到同一个公开目录。
这里用 url_title 单独表达「公开 URL 里的标题段」。slug 在很多系统里表示 URL 友好的短名,也常被当成标题别名、路由别名或文件名派生值;为了避免这些含义混在一起,我选择了更直接的字段名。url_title 不是中文标题的别名,也不参与页面标题展示。
发布日期、页面标题和公开地址分别从这里取值:
| 信息 | 来源 |
|---|---|
| 发布日期 | 文件名前缀 |
| 页面标题 | 文件名里的中文标题 |
| 公开 URL 标题段 | frontmatter url_title |
资源目录也要一起处理
文章文件名改完以后,还有一个很容易漏掉的地方:source/files。
如果资源目录还叫:
source/files/static-site-cache-control-oss/
那本地查找时还是要靠英文 URL 标题段关联文章。文章源码已经变成「日期 + 中文标题」,资源目录却停在旧名字,两边又开始脱节。
所以本地资源目录也应该跟文章文件名保持一致:
source/_posts/2026-06-02 - 静态站点缓存怎么配:从 Hexo 全站协商到 Astro 的 hash 资源.md
source/files/2026-06-02 - 静态站点缓存怎么配:从 Hexo 全站协商到 Astro 的 hash 资源/
公开图片地址、下载地址和已经分享出去的资源 URL 仍使用 url_title,不能跟本地中文目录一起改名:
/files/static-site-cache-control-oss/images/example.png
源码 Markdown 可以使用本地相对路径,让预览器找到图片。例如 ../files/YYYY-MM-DD - 中文标题/images/example.png;含空格时写成 ](<../files/...>)。这需要渲染过滤器把真实图片和链接 token 转换回公开地址,不能把本地目录名直接留在最终 HTML 里。
资源这里分成两层:
| 信息 | 来源 |
|---|---|
| 本地资源源码目录 | source/files/YYYY-MM-DD - 中文标题/ |
| 公开资源 URL | /files/<url_title>/... |
Hexo 默认会把 source/files/xxx 原样发布成 /files/xxx。为了保持公开 URL,需要在生成阶段映射 route;为了同时支持源码 Markdown 预览,还要在渲染阶段转换相对链接。这是两个不同的处理时机。
资源 route 映射和相对链接转换由站点根目录的 scripts/publish-files-by-url-title.js 处理,完整实现与接入示例见资源路由源码弹窗。
如果资源里有独立 HTML demo 或 playground,skip_render 匹配的是本地源码路径。旧配置若写死了英文目录名,迁移时也要调整;本博客现在使用统一 glob,避免逐篇登记:
skip_render:
- "files/**/playground.html"
skip_render 匹配本地源码路径;公开页面使用映射后的 URL。源码 Markdown 选择本地相对链接时,需要和前面的 renderer 转换一起接入。
frontmatter 只保留必要字段
文件名已经包含日期和中文标题以后,frontmatter 里的 date 和 title 就变成了重复信息。
重复字段最麻烦的是不一致时要判断听谁的。比起多写几行,更容易出问题的是事实来源打架:如果文件名叫「静态站点缓存怎么配」,YAML 里 title 还是旧标题,问题会很难排查。日期也是一样:文件名前缀和 YAML date 如果不一致,文章到底应该按哪一天发布?
所以默认 frontmatter 只保留:
---
url_title: hexo-post-filename-url-order
---
Hexo 可以从文件名解析发布日期和标题。后续如果某篇文章确实需要标签、摘要、置顶、封面图,再单独加回对应字段;默认不要为了习惯保留一组可能互相打架的重复事实。
这里还有一个展示细节。以前 YAML 里的 date 只写日期,不写时间。迁移后,Hexo 从文件名只能解析出日期,时间会落在零点。只要主题继续显示上午、下午或凌晨,文章页就会露出「凌晨」;读者不关心发布时间里的小时数,页面日期格式因此改成只显示日期。
同一天多篇文章用稳定二级排序
去掉时间以后,同一天多篇文章会有一个排序问题。
如果只配置:
index_generator:
order_by: -date
跨日期排序没有问题,但同一天的文章 date 完全相等。它们的相对顺序可能受到文件扫描顺序、内部集合顺序或生成器实现影响,不应该被当成稳定规则。
为了排序写一个假的 10:30:00,会把展示层不需要的信息重新写进数据里。这里继续用已经存在、而且稳定的 url_title 做二级排序:
index_generator:
order_by: "-date url_title"
archive_generator:
order_by: "-date url_title"
category_generator:
order_by: "-date url_title"
tag_generator:
order_by: "-date url_title"
主排序仍然是日期倒序。同一天里,再按 url_title 升序。它本来就是长期稳定的公开 URL 标题段,用它做二级排序不会引入新字段,也不会让中文文件名变化影响列表顺序。
Fluid 主题里分类折叠列表也要同步:
category:
post_order_by: "-date url_title"
这样首页、归档、分类、标签里的文章顺序都走同一套规则。
搜索索引也要保持稳定
页面列表稳定以后,还要看生成产物里有没有别的地方仍然只按 -date 排。
Fluid 的本地搜索生成器会生成 local-search.xml。它内部硬编码了 locals.posts.sort('-date')。这不影响页面列表,但会让搜索索引里的同日文章顺序仍然不稳定。静态站点的产物如果每次生成都有无意义 diff,后面检查和发布都会变吵。
这里没有去改 node_modules,而是在 Hexo 的 after_generate 阶段处理 route 里的 XML 数据,把 <entry> 按同样规则重排。
这个文件直接放在站点根目录的 scripts/stabilize-local-search-order.js 就会生效,不需要在 _config.yml、package.json 或其他入口文件里显式声明。scripts/ 是 Hexo 官方目录结构下的约定,适合放简单扩展;如果项目里还没有这个目录,手动新建一个就行。Hexo 初始化时会自动加载里面的 JavaScript 文件,脚本执行时可以直接访问 hexo 对象,所以在文件里调用 hexo.extend.filter.register(...) 就能注册过滤器。
放好以后,下一次 hexo generate 或 hexo server 启动时就会加载它;如果本地服务已经在跑,最好重启一次,避免继续用旧的加载状态。
搜索排序脚本也在同一份源码包中,完整实现与接入示例见搜索排序源码弹窗。
after_generate 发生在 Hexo route 已经生成之后、public 文件写入之前。在这个阶段直接改 public/local-search.xml,后面仍会被 Hexo 写文件覆盖;修改 route 中的数据,才能让最终写出的文件保持稳定顺序。
这个脚本也有边界。文章源码、资源路径、公开 URL 仍然依赖 Hexo 原生规则;脚本只补主题搜索索引的排序稳定性,不参与文章资产发布。
同步主题里的标签、分类和摘要
frontmatter 默认不再写 tags 和 categories 以后,主题展示也要跟着改。否则 YAML 是干净了,页面上还留着空标签、空分类入口,读者会以为站点缺内容或坏掉。
所以这次也把导航里的分类、标签入口关掉,把文章卡片里的分类和标签 meta 关掉。以后如果重新开始维护这些字段,再把入口恢复回来。
这轮迁移时曾删掉 description,让主题回退到正文自动摘要。后来为了让首页摘要稳定,本博客又为文章显式维护 description,通常写 120–180 个中文字符。它承载独立摘要,不是重复文件名中的标题;如果继续使用自动摘要,则要核对正文开头在首页是否适合截取。
迁移前先核对旧文章与资源 URL。这套流程以带日期的稳定英文标题段为前提,不同 permalink 规则需要先列出地址映射。完整内容见文件名与 URL 方案的源码弹窗。
总结
这次整理把本地文件的可读性、公开地址和列表顺序分别交给文件名、url_title 与排序配置。文章和资源在本地按「日期 + 中文标题」归档,公开文章地址和 /files/<url_title>/... 继续保持稳定;资源的 route 映射与 Markdown 相对链接转换,分别保证发布路径和源码预览能同时成立。
同日文章用 url_title 做稳定二级排序,Fluid 搜索索引再通过生成阶段的过滤器补齐同一规则,省去了为排序维护虚构时间。这样改标题、整理目录就不必牵动公开地址;真正需要改 URL 时,再单独处理旧链接映射和重定向。资源路径不带日期,因此这里的 url_title 仍需保持全站唯一。
文件名与 URL 方案的源码
直接复用这套做法
迁移现有 Hexo 博客时,先固定旧 URL,再调整本地文件名和资源路径。
先看清当前文章里哪些字段参与公开 URL。这里的前提是旧 URL 形如:
/YYYY/MM/DD/old-url-title/
如果当前站点的 permalink 不带日期,或者历史上已经改过 URL 规则,就不要直接套这套迁移,先把旧地址映射关系列出来。
第一步,把每篇文章的公开 URL 标题段固定到 frontmatter:
---
url_title: static-site-cache-control-oss
---
这个值要等于旧 URL 里的标题段。后面文件名怎么改,都不要顺手改它。
第二步,改 _config.yml:
permalink: :year/:month/:day/:url_title/
new_post_name: :year-:month-:day - :title.md
use_filename_as_post_title: true
use_slug_as_post_title: true
index_generator:
order_by: "-date url_title"
archive_generator:
order_by: "-date url_title"
category_generator:
order_by: "-date url_title"
tag_generator:
order_by: "-date url_title"
如果主题还有自己的文章排序配置,也要找一遍。我这里还同步了 Fluid 的:
category:
post_order_by: "-date url_title"
第三步,把文章文件改成:
source/_posts/YYYY-MM-DD - 中文标题.md
迁移时先从原来的 date 和 title 取值,确认没有同名冲突,再移动文件。移动后可以去掉重复的 title 和 date,保留 url_title;独立维护的 description、标签等字段按实际用途保留,不随文件改名删除。这一步最好用脚本做,但脚本要先 dry run,打印旧文件名、新文件名和 URL 标题段,确认无误再真正改文件。
第四步,如果有 source/files/<url_title>/ 资源目录,把它们改成 source/files/YYYY-MM-DD - 中文标题/。已经使用的 /files/<url_title>/... 公开链接继续有效。需要源码预览时,可将正文图片改为对应本地目录的相对路径,并配套接入 scripts/publish-files-by-url-title.js 的 renderer 转换和 route 映射,让生成页面仍输出旧公开地址。
第五步,同步主题展示。默认不再维护 tags 和 categories 时,导航、文章卡片和侧边栏里相关入口也要关掉。否则数据源没了,页面还留着入口,会比不改更像坏掉。
第六步,加上 scripts/stabilize-local-search-order.js。把源码包中的对应脚本保存到站点根目录的 scripts/ 下面即可;如果没有这个目录,就新建一个。不需要额外 import,也不需要在 _config.yml 里写插件名。它属于 Hexo 站点脚本;Hexo 初始化时会自动加载它,然后执行里面的 hexo.extend.filter.register('after_generate', ...)。
如果没有启用 Fluid local search,或者主题搜索索引本来就支持自定义排序,这一步可以跳过。
最后做本地验证:
pnpm exec hexo clean && pnpm exec hexo generate
验证时至少看四件事:旧文章 URL 是否还存在,旧 /files/<url_title>/... 资源 URL 是否还存在,页面标题是否来自中文文件名,首页、归档和搜索索引里的同日文章顺序是否稳定。
Fluid 搜索索引稳定排序
资源路由和相对图片转换
请接入 https://shengsheng.fun/files/hexo-post-filename-url-order/kits/hexo-post-routing-order/。先读 README.md、MANIFEST.json、AGENT_PROMPT.md,再按 FILES.json 读取源码;核对 Hexo、renderer 和主题版本,合并 copy/scripts/ 与示例配置,并验证旧 URL、图片与同日排序。