一、前言

Butterfly 默认的分类页和标签页可以列出名称与文章数量,但很难直接回答“这个分类最近写了什么”“某个标签经常和哪些标签一起出现”“能不能在当前页面搜索与排序”。本篇将从默认列表开始,一步一步把分类与标签改造成一张内容地图。

完成后,分类总览会显示说明、图标、颜色、最近文章和十二个月活跃度;标签总览会支持即时搜索、热度排序、最近使用排序和关联标签;分类与标签详情页也会获得统一的文章列表和横向导航。

交互演示负责展示文件与页面如何逐步变化,并不会在访客浏览器中执行 Hexo。真实数据统计仍在 hexo generate 阶段完成。

二、先看从默认列表到内容地图的过程

本次改造包含四个核心文件:

Hexo 博客根目录
├─ source/_data/taxonomy.yml # 分类图标、颜色与说明
├─ scripts/blog-taxonomies.js # 构建数据快照并生成页面
├─ source/js/blog-taxonomies.js # 搜索、排序与 PJAX 初始化
└─ source/css/blog-taxonomies.css # 总览和详情页样式

分类与标签内容地图流程

三、开始前的准备

先确认分类页和标签页已经存在:

source/categories/index.md
source/tags/index.md

它们通常分别包含:

---
title: 分类
type: categories
---
---
title: 标签
type: tags
---

在修改前执行一次:

hexo clean
hexo generate

保证原有分类、标签和详情页能够正常生成。不要直接修改 public/categories/public/tags/ 或主题依赖中的模板。

四、第一步:为分类建立展示配置

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

categories:
交易笔记:
icon: fa-chart-line
color: "#1b9aaa"
order: 10
description: 记录市场观察、交易逻辑与风险判断。

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

足迹世界:
icon: fa-map-location-dot
color: "#836dcc"
order: 60
description: 收录远行、自然与地理坐标中的故事。

字段含义如下:

  • icon:Font Awesome 图标类名;
  • color:分类主色;
  • order:总览页排序,数值越小越靠前;
  • description:分类卡片说明。

普通标签不需要写进 YAML,它们仍然从文章 Front Matter 自动产生。未配置的分类也能显示,只是使用默认图标、颜色和说明。

五、第二步:创建构建期数据适配器

在项目根目录新建 scripts/blog-taxonomies.js。Hexo 的文章、分类和标签有时是数组,有时是查询集合,先统一转换:

'use strict'

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()
: []
}

const dateValue = value => {
if (value && typeof value.valueOf === 'function') {
return Number(value.valueOf()) || 0
}
const time = new Date(value).getTime()
return Number.isNaN(time) ? 0 : time
}

const sortedPosts = posts =>
[...posts].sort((a, b) => dateValue(b.date) - dateValue(a.date))

现在可以读取 Hexo 本地数据:

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

六、第三步:读取并校验 taxonomy.yml

配置最终会进入 HTML 的 classstyle,因此需要限制格式:

const DEFAULT_COLOR = '#5f7d95'
const DEFAULT_ICON = 'fa-folder-open'

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

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

const categoryConfig = name => {
const configured =
hexo.locals.get('data')?.taxonomy?.categories?.[name] || {}

return {
icon: safeIcon(configured.icon),
color: safeColor(configured.color),
order: Number.isFinite(Number(configured.order))
? Number(configured.order)
: 999,
description: String(configured.description || '尚未添加分类说明。')
}
}

图标或颜色写错时会回退,不会让一条配置破坏整个页面。

七、第四步:一次构建一份数据快照

总览和详情页都需要相同的文章关系。不要每渲染一个页面就重新统计,使用缓存快照:

let cachedSnapshot

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

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

buildSnapshot() 中先整理文章与稳定编号:

const buildSnapshot = () => {
const posts = sortedPosts(collectionItems(hexo.locals.get('posts')))
const chronological = [...posts]
.sort((a, b) => dateValue(a.date) - dateValue(b.date))

const serials = new Map(
chronological.map((post, index) => [String(post.path), index + 1])
)

// 后续继续生成 categoryRecords 与 tagRecords
}

同一次 hexo generate 只计算一次;下次生成前清空缓存,新增文章才会进入新快照。

八、第五步:计算十二个月分类活跃度

先以最新文章所在月份为终点,建立连续十二个月并全部补 0:

const months = monthWindow(latestPost.date)
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)
}
})

再按该分类中最高的月份计算柱高:

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

先补 0 可以保证没有发文的月份仍占据正确位置。这组柱子表达的是“分类自己的发布节奏”,不是跨分类的绝对比较。

九、第六步:计算关联标签

关联标签使用共同出现次数,不需要人工维护:

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)

如果一篇文章同时有 HexoButterfly网站魔改,统计 Hexo 时,另外两个标签的关联次数都会加 1。

十、第七步:生成带搜索数据的标签卡片

静态博客没有搜索接口,因此构建时把可搜索文本写进 data-*

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

渲染函数需要对文本做 HTML 转义,并用 urlFor() 统一处理站点根路径。最终在 after_render:html 中判断页面类型:

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

命中后替换 <main> 内容,并只给这些页面加载专用 CSS 与 JavaScript。

十一、第八步:在浏览器中完成搜索与排序

新建 source/js/blog-taxonomies.js

(() => {
const normalize = value =>
String(value || '').trim().toLocaleLowerCase('zh-CN')

const initTagMap = root => {
if (!root || root.dataset.taxonomyBound === 'true') return
root.dataset.taxonomyBound = 'true'

const input = root.querySelector('[data-tag-search-input]')
const sort = root.querySelector('[data-tag-sort]')
const grid = root.querySelector('[data-tag-grid]')
const items = [...root.querySelectorAll('[data-tag-item]')]

const refresh = () => {
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)
})
}

input?.addEventListener('input', refresh)
sort?.addEventListener('change', refresh)
refresh()
}
})()

append() 移动的是现有 DOM 元素,不会复制卡片。初始化标记则避免 PJAX 多次切换后重复绑定事件。

十二、第九步:加入局部样式

新建 source/css/blog-taxonomies.css。至少定义统一主色、卡片网格和手机断点:

.blog-taxonomy {
--tax-accent: var(--theme-color, #4f7ee8);
color: var(--font-color);
}

.blog-category-grid,
.blog-tag-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}

.blog-category-card,
.blog-tag-card {
border: 1px solid rgba(127, 127, 127, 0.18);
border-radius: 16px;
background: var(--card-bg);
}

@media (max-width: 768px) {
.blog-category-grid,
.blog-tag-grid {
grid-template-columns: 1fr;
}
}

实际项目中,总览头部、关联标签、活跃度、详情页横向目录和分页都继续使用 blog-taxonomy 命名空间,避免污染 Butterfly 其他页面。

十三、第十步:生成并验证

执行:

npm run clean
npm run build
npm run verify
hexo server

依次打开:

http://localhost:4000/categories/
http://localhost:4000/tags/
http://localhost:4000/categories/建站笔记/
http://localhost:4000/tags/hexo/

检查以下项目:

  1. 分类数量、标签数量和文章数量正确;
  2. 搜索“hexo”只留下匹配卡片;
  3. 热度、最近使用和名称排序都稳定;
  4. 分类详情与标签详情的分页地址正常;
  5. PJAX 往返后操作一次只触发一次;
  6. 手机端没有横向溢出;
  7. 深色模式下卡片、边框和输入框可读。

十四、以后怎样维护

  • 新增普通标签:只修改文章 Front Matter;
  • 新增分类:可选地在 taxonomy.yml 增加图标、颜色、顺序和说明;
  • 修改关系计算:编辑 scripts/blog-taxonomies.js
  • 修改搜索排序:编辑 source/js/blog-taxonomies.js
  • 修改外观:编辑 source/css/blog-taxonomies.css

十五、常见问题

1. 分类存在但没有颜色

检查 YAML 分类名是否与文章中的分类完全一致,并确认颜色是 #RGB#RRGGBB 等十六进制格式。

2. 新文章没有进入统计

执行 hexo clean 清除数据库缓存,再重新生成。还要确认文章不是草稿,并且日期能够被 Hexo 正确解析。

3. 搜索操作一次触发多次

确认初始化前存在 data-taxonomy-bound 防重复标记,并在 pjax:complete 中只调用统一入口。

4. 修改脚本后线上仍是旧效果

更新专用 JS/CSS URL 的版本号,并检查 CDN 与浏览器缓存,而不是反复修改数据逻辑。

十六、结语

分类与标签内容地图的关键不在卡片样式,而在于先把 Hexo 的文章关系整理成稳定快照,再把浏览器真正需要的搜索字段写入静态 HTML。这样既保留了 Hexo 的纯静态部署优势,也获得了搜索、排序、关联推荐和详情导航能力。