一、前言
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 ---
|
在修改前执行一次:
保证原有分类、标签和详情页能够正常生成。不要直接修改 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 的 class 和 style,因此需要限制格式:
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]) )
}
|
同一次 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)
|
如果一篇文章同时有 Hexo、Butterfly 和 网站魔改,统计 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/
|
检查以下项目:
- 分类数量、标签数量和文章数量正确;
- 搜索“hexo”只留下匹配卡片;
- 热度、最近使用和名称排序都稳定;
- 分类详情与标签详情的分页地址正常;
- PJAX 往返后操作一次只触发一次;
- 手机端没有横向溢出;
- 深色模式下卡片、边框和输入框可读。
十四、以后怎样维护
- 新增普通标签:只修改文章 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 的纯静态部署优势,也获得了搜索、排序、关联推荐和详情导航能力。