分类与标签合并页:从两个空页面到一个内容索引

博客的「分类」和「标签」两个页面,一直是两块没人管的空地。

分类页打开是这样:

目前共计 9 个分类

  • 技术分享 15
  • 算法竞赛 6

标签页是 NexT 原生的 tagcloud,59 个标签,字号全都差不多,挤成一坨。

问题不只是「空」。更根本的是这两个页面的内容高度重叠——分类是树状的,标签是网状的,但它们指向的其实是同一批 21 篇文章。摆两个菜单项让人二选一,是把同一个问题拆成了两半。

这篇把两页合并成一个内容索引,顺手记录几个踩坑。

一、合并后的结构

新的 /topics/ 页按四段组织,每段回答一个不同的问题:

内容索引页结构

分段回答的问题设计要点
概览横幅这站有多少东西?21 篇 / 9 分类 / 59 标签 / 32 天跨度
分类树这站聊什么方向?树状卡片,子分类缩进 + 层级竖线
标签云哪些话题被反复提?5 档字号,按文章数分级
最近更新从哪篇看起?日期 + 标题 + 分类胶囊

分类和标签的分工在文案里讲清楚了:「分类看主题脉络(树状、有层级),标签看交叉话题(网状、扁平)」。这样读者用哪种方式找文章都是通的,不用先理解 Hexo 的数据模型。

二、两个页面的 URL 怎么办

合并页面最大的隐患是旧 URL 404。/categories/ 和 /tags/ 很可能已经有搜索引擎收录和外部引用,直接删掉会掉权重。

做法是留一个跳转页:

1
2
3
4
<meta http-equiv="refresh" content="0; url=/topics/">
<script>location.replace('/topics/' + location.hash);</script>
<link rel="canonical" href="/topics/">
<meta name="robots" content="noindex, follow">

四个细节:

  • meta refresh + location.replace 双保险,前者对不支持 JS 的爬虫有效
  • location.replace 而不是 location.href —— 前者不往历史记录里塞一条,回退键不会把用户卡在跳转页里
  • canonical 指向新页,把权重集中过去
  • noindex, follow 告诉爬虫「别索引这个跳转壳,但顺着里面的链接继续爬」

跳转页用 layout: false 完全脱离 NexT 布局,755 字节,比渲染一整套主题再跳转快得多。

三、为什么在构建期渲染而不是前端

分类树是层级结构,前端拿不到结构化数据。最直觉的做法是让页面自己发请求读 JSON,但那样首屏会有明显的空白。

我的选择是在 after_render:html filter 里直接把 HTML 算好塞进去(和这个博客的「首页统计胶囊」是同一个套路):

1
2
3
4
5
6
7
hexo.extend.filter.register('after_render:html', function (result) {
if (typeof result !== 'string' || result.indexOf('topics-hero') === -1) return result;
const get = (name) => hexo.locals.get(name);
const cats = get('categories').toArray().filter(c => c.length > 0);
const tags = get('tags').toArray();
// ...生成 HTML,替换占位符
}, 20);

几个细节:

  • result.indexOf('topics-hero') === -1 提前返回 —— 200 多个页面绝大多数没有占位符,省掉一次正则扫描
  • 过滤 c.length > 0 —— Hexo 会给 front-matter 里写了但没用到的分类建对象,不过滤会渲染出空分类
  • 标签排序加 localeCompare 兜底 —— 只按文章数排的话,同数的标签顺序取决于数据顺序,每次构建 diff 都有噪音

首屏即内容,没有 loading 状态,pjax 切换也是瞬时的。

四、颜色:每个分类一个稳定色相

「技术分享」和「算法竞赛」长得不一样,读者扫一眼就能区分开,这是分类页该有的视觉锚点。

实现上有个不明显的选择:色相由分类名散列决定,而不是随便分配:

1
2
3
4
5
function hueOf(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CAT_HUES[h % CAT_HUES.length];
}

好处是零维护——以后新增分类,不用去配置文件里挑颜色,它自己就有。

五、踩的三个坑

坑一:Warehouse 不是数组

写「最近更新」时,我需要取每篇文章的顶级分类:

1
const topCat = p.categories[0].categories ? p.categories[0].categories[0].name : p.categories[0].name;

这段代码跑出来的结果是整个页面 0 字节,而构建日志干干净净:

1
INFO  216 files generated in 7.4 s

Hexo 会吞掉 filter 里抛的异常。排查过程很曲折——先怀疑 p.categories 是空数组,加了日志才发现是类数组对象:

1
2
3
4
typeof cats=object  isArray=false  len=2
cats keys=data,length
Array.from len=2 first=UNDEFINED ← 迭代出来全是 undefined
toArray len=2 first=技术分享 ← 正确姿势

Array.from() 对一个只暴露 data / length 的对象迭代出的是 undefined,然后 .categories 就炸了。

正确写法:

1
const cats = p.categories && p.categories.toArray ? p.categories.toArray() : [];

顺手记一条自检铁律(这个博客踩过好几次):
改完 scripts/ 下的 filter,除了看构建输出,必须
stat -c%s public/topics/index.html 确认不是 0。

坑二:stylus 会预求值 CSS 变量

想让分类卡片带自己的色相,第一反应是写:

1
2
3
4
.cat-icon {
background: hsla(var(--cat-hue), 62%, 62%, 0.16);
color: hsl(var(--cat-hue), 58%, 46%);
}

stylus 直接编译失败:

1
2
3
TypeError: expected "hue" to be a unit, but got call:var(--cat-hue)
at exports.assertType
at Evaluator.hsla

stylus 认为 hsla() 是它自己的内置函数,会在编译期求值。传进去的 var(--cat-hue) 不是它认识的单位,整个表达式就崩了。

而且这次崩得比 0 字节更隐蔽——整份 styles.styl 编译失败,main.css 产出 0 字节,全站所有页面裸奔,而构建依然打印「216 files generated」。

这和记忆里那条「stylus 不认 CSS min()」是同一个病的不同症状:
凡是 stylus 有同名内置函数的关键字,都会在编译期被求值,不能塞 CSS 变量。
min()、hsla()、rgba()、calc() 全都在列。

解法是绕开所有内置函数——在 JS 里把完整颜色算好,作为 inline custom property 注入:

1
2
3
4
5
6
7
8
9
10
function catVars(name) {
const h = hueOf(name);
return [
'--cat-soft:hsl(' + h + ',62%,62%,0.16)',
'--cat-ink:hsl(' + h + ',58%,46%)',
// 深色模式的两档也一起算好
'--cat-soft-d:hsl(' + h + ',45%,62%,0.2)',
'--cat-ink-d:hsl(' + h + ',62%,74%)'
].join(';');
}

CSS 里只写 var(--cat-soft),纯 CSS 变量,stylus 不介入求值。

顺带说,深色模式的两档颜色提前算好是个额外好处:body.darkmode 分支里就只剩纯粹的 var() 替换,不用再写一遍 HSL 计算。

坑三:auto-fill 撑出的空列

第一版分类树用 grid-template-columns: repeat(auto-fill, 1fr),结果只有 2 个顶层分类时,容器宽度被硬撑成 3 列,右侧空出一大块。

1
2
3
4
.cat-tree {
grid-template-columns: repeat(auto-fill, 288px);
justify-content: start;
}

固定列宽 + justify-content: start:卡片按实际数量靠左排,剩余空间自然留白但不成列。

六、几个可复用的经验

1. Array.from() 不等于「类数组」安全。 只要对象只暴露 data / length 而没有 Symbol.iterator,Array.from 就会产出一堆 undefined。Hexo 的 Warehouse 一律用 .toArray()。

2. 构建期渲染的第一原则是「产物非 0 字节」。 构建日志永远好看,产物大小才是唯一可信的信号。改完 filter 必查。

3. 合并页面时先想清楚旧 URL。 跳转页 + canonical + noindex, follow 三件套,比直接 404 优雅。

4. 视觉编码要稳定。 色相由分类名散列得出,意味着新增分类零维护、重新构建结果不变。

5. 合并不等于删信息。 分类和标签合并进一页,但两者的分工要在文案里讲明白——否则只是把两个空页面换成一个看不懂的页面。

七、最终效果

  • 菜单从 9 项减到 8 项,「分类」「标签」变成一个「分类标签」
  • 概览横幅给出全局体量,分类树给方向,标签云给话题,最近更新给出入口
  • 分类卡片按名散列色相,子分类带层级竖线和彩色圆点
  • 标签云 5 档字号,一眼看出哪些是被反复写的主题
  • 浅色 / 深色 / 移动端三种形态均已截图验证

关于「内容索引」这个定位:分类和标签本质上都是给同一批文章建的不同索引。与其让读者在两棵不相干的树里找同一篇文章,不如把它们并排放到一个页面上,让读者自己决定用哪条路径切入。工具的价值不在于分类得多细,而在于找东西的路径有多短。