把 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 长期稳定 | 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 仍然不变。
这里没有继续用 slug 这个词。slug 在很多系统里表示 URL 友好的短名,但它也常被当成标题别名、路由别名或文件名派生值。这里真正要表达的是「公开 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 资源/但这里不能直接把 source/_posts/*.md 里的 /files/... 链接也改成中文目录。Markdown 图片地址、下载地址和外部分享出去的资源地址都是公开 URL,它们最终会原样出现在页面 HTML 里,所以仍然应该继续使用 url_title:
/files/static-site-cache-control-oss/images/example.png也就是说,资源这里也分成两层:
| 信息 | 来源 |
|---|---|
| 本地资源源码目录 | source/files/YYYY-MM-DD - 中文标题/ |
| 公开资源 URL | /files/<url_title>/... |
Hexo 默认会把 source/files/xxx 原样发布成 /files/xxx。为了让本地目录改名以后公开 URL 不变,需要加一个很小的站点脚本,把生成阶段的 route 映射回 url_title。
这个文件放在站点根目录的 scripts/publish-files-by-url-title.js:
'use strict';
const path = require('path');
const FILES_PREFIX = 'files/';
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', chunk => chunks.push(Buffer.from(chunk)));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
function getPostSourceName(post) {
const source = post.source || '';
const extension = path.extname(source);
return path.basename(source, extension);
}
function buildFilesRouteMap(hexo) {
const posts = hexo.locals.get('posts').toArray();
const routeMap = new Map();
for (const post of posts) {
const sourceName = getPostSourceName(post);
const urlTitle = post.url_title;
if (!sourceName || !urlTitle || sourceName === urlTitle) continue;
// 本地资源目录跟随文章文件名,公开资源 URL 仍跟随 url_title。
routeMap.set(`${FILES_PREFIX}${sourceName}/`, `${FILES_PREFIX}${urlTitle}/`);
}
return routeMap;
}
function getMappedRoute(routePath, routeMap) {
for (const [sourcePrefix, targetPrefix] of routeMap) {
if (routePath.startsWith(sourcePrefix)) {
return `${targetPrefix}${routePath.slice(sourcePrefix.length)}`;
}
}
return null;
}
hexo.extend.filter.register('after_generate', async function() {
const routeMap = buildFilesRouteMap(this);
if (!routeMap.size) return;
const moves = [];
const routePaths = this.route.list().filter(routePath => routePath.startsWith(FILES_PREFIX));
for (const routePath of routePaths) {
const targetPath = getMappedRoute(routePath, routeMap);
if (!targetPath || targetPath === routePath) continue;
const stream = this.route.get(routePath);
if (!stream) continue;
// 先读出原 route,再删除中文源目录对应的公开路径,避免生成两份资源 URL。
moves.push({
sourcePath: routePath,
targetPath,
modified: this.route.isModified(routePath),
data: await streamToBuffer(stream)
});
}
for (const move of moves) {
this.route.set(move.targetPath, {
data: move.data,
modified: move.modified
});
this.route.remove(move.sourcePath);
}
});如果资源里有独立 HTML demo 或 playground,skip_render 里登记的是源码路径,所以也要跟着改成本地目录名:
skip_render:
- "files/YYYY-MM-DD - 中文标题/playground.html"这一点和正文链接正好相反:skip_render 服务 Hexo 读源码,写本地目录;正文图片和下载链接服务浏览器访问,写公开 URL。
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 启动时就会加载它;如果本地服务已经在跑,最好重启一次,避免继续用旧的加载状态。
完整脚本是这样:
'use strict';
const POST_URL_RE = /<url>[^<]*\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)\/<\/url>/;
const ENTRY_RE = /\s*<entry>[\s\S]*?<\/entry>/g;
function compareText(a, b) {
if (a === b) return 0;
return a < b ? -1 : 1;
}
function getSortKey(entry, index) {
// 从文章 URL 提取日期和 url_title,和页面列表使用同一组排序键。
const match = entry.match(POST_URL_RE);
if (!match) {
// 搜索 XML 里如果混入非文章 entry,保留它们原本的相对顺序。
return {
isPost: false,
index
};
}
return {
isPost: true,
date: `${match[1]}-${match[2]}-${match[3]}`,
urlTitle: match[4],
index
};
}
function compareEntry(a, b) {
if (a.key.isPost !== b.key.isPost) {
return a.key.isPost ? -1 : 1;
}
if (!a.key.isPost) {
return a.key.index - b.key.index;
}
// 先按日期倒序,再用 url_title 做同日文章的稳定二级排序。
const dateOrder = compareText(b.key.date, a.key.date);
if (dateOrder) return dateOrder;
const titleOrder = compareText(a.key.urlTitle, b.key.urlTitle);
if (titleOrder) return titleOrder;
return a.key.index - b.key.index;
}
function stabilizeSearchXml(xml) {
const matches = [...xml.matchAll(ENTRY_RE)];
if (matches.length < 2) return xml;
const entries = matches.map((match, index) => ({
text: match[0],
index: match.index,
key: getSortKey(match[0], index)
}));
// 只替换 entry 列表,保留 XML 头、search 根节点和原有缩进。
const first = entries[0];
const last = entries[entries.length - 1];
const prefix = xml.slice(0, first.index);
const suffix = xml.slice(last.index + last.text.length);
const sorted = entries.slice().sort(compareEntry).map(entry => entry.text).join('');
const nextXml = `${prefix}${sorted}${suffix}`;
return nextXml;
}
function streamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', chunk => chunks.push(Buffer.from(chunk)));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
hexo.extend.filter.register('after_generate', async function() {
const search = this.theme.config.search || {};
if (!search.enable) return;
const generatePath = search.generate_path || '/local-search.xml';
const routePath = this.route.format(generatePath);
const stream = this.route.get(routePath);
if (!stream) return;
// after_generate 里改 route,最终写入 public 的 local-search.xml 才会稳定。
const xml = await streamToString(stream);
const nextXml = stabilizeSearchXml(xml);
if (nextXml !== xml) {
this.route.set(routePath, nextXml);
}
});这里要注意时机。after_generate 发生在 Hexo route 已经生成之后、public 文件写入之前。直接改 public/local-search.xml 太早,后面仍会被 Hexo 写文件覆盖。应该改 route,最终写出的文件才是稳定顺序。
这个脚本也有边界。文章源码、资源路径、公开 URL 仍然依赖 Hexo 原生规则;脚本只补主题搜索索引的排序稳定性,不参与文章资产发布。
主题展示跟着数据一起收紧
frontmatter 默认不再写 tags 和 categories 以后,主题展示也要跟着改。否则 YAML 是干净了,页面上还留着空标签、空分类入口,读者会以为站点缺内容或坏掉。
所以这次也把导航里的分类、标签入口关掉,把文章卡片里的分类和标签 meta 关掉。以后如果重新开始维护这些字段,再把入口恢复回来。
首页摘要也类似。以前 description 可以作为手写摘要;默认不写之后,就回到主题的自动摘要。这个变化能不能接受,取决于文章开头是不是写得像真正的开头。也就是说,摘要质量从 YAML 字段转回正文第一段。
直接复用这套做法
如果要把一个现有 Hexo 博客迁到这套规则,可以按这个顺序做。
先看清当前文章里哪些字段参与公开 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 取值,确认没有同名冲突,再移动文件。移动完以后,frontmatter 里的 title 和 date 就可以去掉,只留下 url_title。这一步最好用脚本做,但脚本要先 dry run,打印旧文件名、新文件名和 URL 标题段,确认无误再真正改文件。
第四步,如果有 source/files/<url_title>/ 资源目录,把它们改成 source/files/YYYY-MM-DD - 中文标题/。正文里的 /files/<url_title>/... 图片和下载链接不要改,它们是公开 URL。然后加上 scripts/publish-files-by-url-title.js,让生成产物继续输出到旧的 /files/<url_title>/...。
第五步,同步主题展示。默认不再维护 tags 和 categories 时,导航、文章卡片和侧边栏里相关入口也要关掉。否则数据源没了,页面还留着入口,会比不改更像坏掉。
第六步,加上 scripts/stabilize-local-search-order.js。把上面那段完整代码保存到站点根目录的 scripts/ 下面即可;如果没有这个目录,就新建一个。不需要额外 import,也不需要在 _config.yml 里写插件名。它不是一个 npm 插件,而是 Hexo 的站点脚本。Hexo 初始化时会自动加载它,然后执行里面的 hexo.extend.filter.register('after_generate', ...)。
如果没有启用 Fluid local search,或者主题搜索索引本来就支持自定义排序,这一步可以跳过。
最后做本地验证:
pnpm exec hexo clean && pnpm exec hexo generate验证时至少看四件事:旧文章 URL 是否还存在,旧 /files/<url_title>/... 资源 URL 是否还存在,页面标题是否来自中文文件名,首页、归档和搜索索引里的同日文章顺序是否稳定。只要这几件事成立,这套迁移就基本落稳了。
总结
这次迁移做的不是单纯改文件名,而是重新分配几个信息的归属:文件名承载日期和中文标题,服务本地查找;url_title 承载公开 URL 标题段,保证旧链接不变;资源源码目录跟着文章文件名走,但公开资源 URL 继续跟着 url_title 走;frontmatter 不再重复保存标题和日期,避免两个事实来源互相打架;同日文章用 -date url_title 固定顺序,搜索索引也按同样规则重排。主题侧同步去掉不再维护的数据入口,只显示读者真正能看到的内容。
改完以后,文章列表在文件系统里更好扫,公开 URL 继续稳定,构建产物也不会因为同一天多篇文章而反复抖动。文件名给人看,URL 给外链看,frontmatter 只保存文件名无法表达、但公开地址必须稳定使用的字段。