跳到正文

把 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 和列表排序各自负责什么

本地查找、公开地址和页面排序分别交给下面三个来源:

目标 负责对象
本地文件好找、好扫读 文件名
公开 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 搜索索引稳定排序

打开原文件 FILES.json

Fluid 搜索索引稳定排序正在加载代码工作区...
资源路由和相对图片转换

打开原文件 FILES.json

请接入 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、图片与同日排序。
资源路由和相对图片转换正在加载代码工作区...