前言

Butterfly 默认的分类页和标签页能列出名称,但很难回答三个更实际的问题:这个分类最近写了什么、某个标签常和哪些标签一起出现、我能否直接搜索和排序?因此,这次改造把两个列表升级成了一张“内容地图”。

一、改造后的信息结构

页面 新增能力
分类总览 分类说明、图标、颜色、文章数量、最近文章、近十二个月活跃度
标签总览 搜索、热度/名称/最近使用排序、关联标签、最近文章
分类详情 横向分类导航、按年份分组的文章列表、全站文章编号
标签详情 标签导航、相关文章、分页、分类和其他标签信息

这不是单纯换一套 CSS。页面所需的大部分信息并不存在于原始模板中,必须先在 Hexo 构建阶段计算出来。

二、数据从哪里来

Hexo 生成网站时,会把文章、分类和标签放进本地数据模型:

const posts = hexo.locals.get('posts')
const categories = hexo.locals.get('categories')
const tags = hexo.locals.get('tags')

这些对象看起来像数组,但实际可能是 Hexo 使用的查询集合。为了让后续代码统一处理,先写一个转换函数:

const collectionItems = collection => {
if (!collection) return []
if (Array.isArray(collection.data)) return collection.data
if (Array.isArray(collection)) return collection
return typeof collection.toArray === 'function'
? collection.toArray()
: []
}

对新手来说,可以把它理解成一个“适配器”:不管 Hexo 交过来的数据是哪种包装,函数最终都返回普通数组。

三、先构建一次数据快照

分类总览、标签总览和每个详情页都需要文章数据。如果每渲染一个页面就重新统计一次,文章越多,构建时重复工作越多。因此脚本把计算结果缓存成快照:

let cachedSnapshot

const getSnapshot = () =>
cachedSnapshot || (cachedSnapshot = buildSnapshot())

hexo.extend.filter.register('before_generate', () => {
cachedSnapshot = undefined
})

一次 hexo generate 中,buildSnapshot() 只执行一次;下一次重新生成网站前清空缓存,确保新增文章能够进入统计。

快照中保存:

  • 按日期排序的全部文章;
  • 分类与标签记录;
  • 名称到记录的 Map 索引;
  • 全站文章编号;
  • 已分类、已打标签的文章数量;
  • 最近一次发布日期。

Map 可以看作一本目录。使用分类名查找记录时,不必每次从数组第一项扫描到最后一项。

四、分类外观为什么单独放进 YAML

Hexo 知道分类名称和文章数量,却不知道“建站笔记应该使用代码图标和蓝色”。这些展示信息放在 source/_data/taxonomy.yml

categories:
建站笔记:
icon: fa-code
color: "#4f7ee8"
order: 30
description: 记录 Hexo、Butterfly 与网站维护实践。

脚本读取配置时还会校验输入:

const safeColor = value =>
/^#[0-9a-f]{3,8}$/i.test(String(value || ''))
? String(value)
: '#5f7d95'

const safeIcon = value =>
/^fa-[a-z0-9-]+$/i.test(String(value || ''))
? String(value)
: 'fa-folder-open'

这样即使颜色或图标写错,也会回退到安全默认值,不会把错误内容直接插进 styleclass

五、近十二个月活跃度怎样计算

每个分类卡片底部有十二根短柱,表示最近十二个月的发布节奏。计算分为三步。

第一步,以最新文章所在月份为终点,生成连续十二个月:

const months = monthWindow(latestPost.date)

第二步,先把十二个月全部初始化为 0,再遍历分类下的文章累加:

const monthCounts = new Map(months.map(month => [month.key, 0]))

categoryPosts.forEach(post => {
const key = formatDate(post.date, 'YYYY-MM')
if (monthCounts.has(key)) {
monthCounts.set(key, monthCounts.get(key) + 1)
}
})

先补 0 很重要。如果某个月没发文章,它仍应占据时间轴中的一个位置,而不是让相邻月份错误地挨在一起。

第三步,以该分类中最高的月份为 100%,换算柱高和透明度:

const ratio = item.count === 0 ? 0 : item.count / max
const height = 4 + Math.round(ratio * 22)

这种归一化展示的是“这个分类自己的发布节奏”,不是不同分类之间的绝对比较。

六、关联标签是怎样算出来的

关联标签没有使用人工配置,而是通过文章中的共同出现次数计算。

假设文章 A 同时包含 HexoButterfly网站魔改。统计 Hexo 时,另外两个标签的关联次数都加 1。遍历完所有含 Hexo 的文章后,按共同出现次数排序,取前两个:

record.posts.forEach(post => {
postTagNames(post).forEach(name => {
if (name !== record.name) {
relatedCounts.set(name, (relatedCounts.get(name) || 0) + 1)
}
})
})

record.related = [...relatedCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 2)

这是一种简单的“共现关系”。它不理解文章语义,但对于博客标签已经足够直观,而且每次构建都会自动更新。

七、标签搜索为什么不需要请求服务器

Hexo 是静态网站,浏览器搜索标签时不能临时查询数据库。因此,构建阶段把可搜索文本写进卡片的 data-* 属性:

<article
data-tag-name="hexo"
data-tag-count="4"
data-tag-latest="1784211200000"
data-tag-search="hexo 建站笔记 butterfly 网站魔改">
</article>

浏览器端收到输入后只做三件事:

const query = normalize(input.value)
const ordered = [...items].sort(compare)

ordered.forEach(item => {
const matched = !query ||
normalize(item.dataset.tagSearch).includes(query)
item.hidden = !matched
grid.append(item)
})
  • includes() 判断是否匹配;
  • hidden 控制是否显示;
  • append() 把同一个 DOM 元素移动到新顺序,不会复制卡片。

排序时根据下拉框选择文章数量、最近使用时间或中文名称,并设置第二、第三排序条件,避免数值相同时顺序反复变化。

八、总览页与详情页怎样接管原页面

脚本在 after_render:html 阶段判断当前页面类型:

const isCategoriesOverview = page.type === 'categories'
const isTagsOverview = page.type === 'tags'
const isCategoryDetail = Boolean(page.category)
const isTagDetail = Boolean(page.tag)

命中后,脚本生成新的主体 HTML,替换 <main> 内原有内容,并只向这些页面注入专属 CSS 和 JavaScript。

详情页仍然使用 Hexo 已经完成分页的 page.posts,因此不会破坏原来的分类和标签地址。脚本只是改变展示结构,没有重新实现路由系统。

九、PJAX 下为什么要防止重复绑定

Butterfly 使用 PJAX 切换页面时,浏览器不会完整刷新。初始化函数可能执行多次,所以脚本先做标记:

if (!root || root.dataset.taxonomyBound === 'true') return
root.dataset.taxonomyBound = 'true'

随后同时监听首次加载和 PJAX 完成事件:

document.addEventListener('DOMContentLoaded', initBlogTaxonomies)
document.addEventListener('pjax:complete', initBlogTaxonomies)

没有这个保护,搜索框和排序框可能被重复绑定,操作一次却触发多次刷新。

十、维护入口

  • 分类颜色、图标、顺序和说明:source/_data/taxonomy.yml
  • 数据整理与页面 HTML:scripts/blog-taxonomies.js
  • 搜索、排序和 PJAX 初始化:source/js/blog-taxonomies.js
  • 卡片、详情页和移动端样式:source/css/blog-taxonomies.css

新增普通标签不需要改代码;新增分类时,即使不写 YAML 也能显示,只是使用默认外观。

结语

这次改造的关键不是卡片样式,而是先把 Hexo 的文章关系整理成可复用快照,再让静态页面拥有搜索、排序和关联推荐能力。