在hexo实现自定义分类页面

上一篇hexo源码分析主要分析了hexo.extend.generator的流程

本篇就是实际使用hexo.extend.generator来实现自定义分类页面的功能

预期是像这个C++大佬的博客https://www.fluentcpp.com/posts/一样,存在一个文章总览目录

可以很清晰的进行分类

但是我的需求还要稍微复杂一些,需要支持多级分类,可以实现出一个简单的思维导图分类,例如使用hexo的分类标注的两篇博文

1
2
3
4
title: 为什么我用这个blog
categories:
- [Blog, Test1]
- [Blog, Test2, Test2_Sub]

以及

1
2
3
title: 如何创建这样一个blog
categories:
- Blog

那么预期如下

1
2
3
4
5
6
7
Blog
如何创建这样一个blog
Test1
为什么我用这个blog
Test2
Test2_Sub
为什么我用这个blog

并且整个页面需要支持分类的全部展开和全部折叠,以及单个分类点击展开和折叠

单纯的折叠功能曾经有人给next提出了类似的需求有方法可以使category的菜单实现折叠吗,但是由于项目的交接,这个issue最后无疾而终,还是得靠自己来实现了

添加模板

首先在眼前的难题就是如何在next主题上新加一个模板,如果直接放到模板文件夹,那以后的next主题更新就很麻烦了

翻到issue上看到Custom File / Theme Injects 是否支持修改原有的代码?

作者回答说需要使用hexo扩展插件https://github.com/jiangtj/hexo-extend-theme

看到有人提到看了这个插件的源码,可以几行代码引入新的模板

正好今天有类似的需求,那个插件为了通用可配置做的逻辑比较复杂,其实替换文件顺着该插件思路,直接在根目录的 scripts 子目录下加一个 js 文件就可以。

1
2
3
4
5
6
const { readFileSync } = require('fs');

hexo.extend.filter.register('before_generate', () => {
const file = readFileSync('layout/notes.njk').toString();
hexo.theme.setView('notes.njk', file);
})

其中 layout/notes.njk 是相对根目录的路径,也就是你自定义的文件。hexo.theme.setView('notes.njk', file); 插入或覆盖原有相对于 layout 路径为 notes.njk 的文件。如有多个文件待替换,则重复中间两行。

所以在根目录的scripts下新增custom-category.js,用于给hexo框架注册模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { readFileSync } = require('fs');

hexo.extend.filter.register('before_generate', () => {
const file = readFileSync('layout/custom-category.njk').toString();
hexo.theme.setView('custom-category.njk', file);
})

hexo.extend.generator.register('custom-category', function (locals) {
var categoriesTree = []; //分类树实现在下一节
return {
path: 'custom-categories/index.html',
data: {
categoriesTree: categoriesTree, //传参给模板文件
},
layout: ['custom-category']
}
});

随后就可以新建模板文件layout/custom-category.njk,网站的custom-categories/index.html页面就会使用custom-category.njk来渲染

根据之前的hexo源码分析,这个模板文件可以使用page.categoriesTree来接收hexo.extend.generator注册函数的返回值

实现分类树

hexo的数据结构是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Post {
string name;
string _id;
string slug;
string path;
string link;
Category* owner;

...其他字段省略
};

class Category {
string name;
string _id;
string slug;
string path;
string permalink;
Post** posts; //这是一个Post指针的数组
string parent; //指向父节点的_id,如果父节点不存在,为空
...其他字段省略
};

由于Category和Post形成了环,因此不能直接json序列化打印内容,只能用Object.keys(category)方法查看字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
locals.categories.forEach(category => {
//hexo.log.info('category.keys:', Object.keys(category));
hexo.log.info('category.name:', category.name);
hexo.log.info('category.id:', category._id);
hexo.log.info('category.slug:', category.slug);
hexo.log.info('category.path:', category.path);
hexo.log.info('category.permalink:', category.permalink);
hexo.log.info('category.parent:', category.parent);
category.posts.forEach(post => {
//hexo.log.info('post.keys:', Object.keys(post));
hexo.log.info('post.id:', post._id);
hexo.log.info('post.slug:', post.slug);
hexo.log.info('post.path:', post.path);
})
})

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
INFO  category.name: Blog
INFO category.id: cm0r3lkxy0007fsv32fez76fi
INFO category.slug: Blog
INFO category.path: categories/Blog/
INFO category.permalink: https://weakyon.com/categories/Blog/
INFO category.parent: undefined

INFO post.id: cm0r3lkxr0001fsv3fva40vrq
INFO post.slug: why-I-use-this
INFO post.path: 2011/01/01/why-I-use-this.html

INFO post.id: cm0r3lky7000qfsv3869l2qdh
INFO post.slug: how-to-setup-this-blog
INFO post.path: 2014/08/20/how-to-setup-this-blog.html

---------------------------------------------------------------

INFO category.name: Test1
INFO category.id: cm0r3lky3000efsv3cjmyhlz3
INFO category.slug: Blog/Test1
INFO category.path: categories/Blog/Test1/
INFO category.permalink: https://weakyon.com/categories/Blog/Test1/
INFO category.parent: cm0r3lkxy0007fsv32fez76fi

INFO post.id: cm0r3lkxr0001fsv3fva40vrq
INFO post.slug: why-I-use-this
INFO post.path: 2011/01/01/why-I-use-this.html

那么遍历category,创建它的哈希表,主键为category._id,值为Category添加了children列表的数据结构

然后再遍历一次category,如果存在parent,就把他挂到Map[parent].parent下面,就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const { readFileSync } = require('fs');

hexo.extend.filter.register('before_generate', () => {
const file = readFileSync('layout/custom-category.njk').toString();
hexo.theme.setView('custom-category.njk', file);
})

hexo.extend.generator.register('custom-category', function (locals) {

// 建立父子关系的分类数据结构
var categoriesData = locals.categories.toArray();
var categoriesMap = {};

// 创建分类ID映射
categoriesData.forEach(category => {
categoriesMap[category._id] = {
id: category._id,
name: category.name,
slug: category.slug,
posts: category.posts.toArray(),
posts_length: category.posts.length,
children: []
};
});

// 构建树形结构
var categoriesTree = [];
categoriesData.forEach(category => {
if (category.parent) {
const parentId = category.parent;
if (categoriesMap[parentId]) {
categoriesMap[parentId].children.push(categoriesMap[category._id]);
}
} else {
categoriesTree.push(categoriesMap[category._id]);
}
});

return {
path: 'custom-categories/index.html',
data: {
categoriesTree: categoriesTree,
},
layout: ['custom-category']
}
});

但是这样创建出来的Blog分类下,会有子分类的文章,所以需要对其去重,然后再返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 存储子节点的文章id, 并去重
function getSubtreePostIds(category, includeNodePosts) {
let subtreePostIds = new Set();

// 遍历子节点
category.children.forEach(child => {
// 递归获取子节点及其子树的所有文章id
const childPostIds = getSubtreePostIds(child, true);
childPostIds.forEach(id => subtreePostIds.add(id));
});

if (includeNodePosts) {
// 获取当前节点的文章id
const nodePostIds = category.posts.map(post => post._id);

// 连接子节点的文章id以及当前节点的文章id
nodePostIds.forEach(id => subtreePostIds.add(id));
}

return subtreePostIds;
}

// 删除在子节点中存在的文章id
function removeSubtreePostIdsFromNode(category) {
// 遍历子节点
category.children.forEach(child => {
removeSubtreePostIdsFromNode(child);
});

// 获取子节点的文章id
const subtreePostIds = getSubtreePostIds(category, false);

// hexo.log.info('category:', category.name);
// hexo.log.info('category posts:', subtreePostIds);

// 过滤在子节点中存在的文章id
category.posts = category.posts.filter(post => {
return !subtreePostIds.has(post._id);
});

// 更新文章数目
category.posts_length = category.posts.length;
}

// 对于每个节点,删除在子节点中存在的文章id
categoriesTree.forEach(removeSubtreePostIdsFromNode);

实现模板

模板是上文使用readFileSync注入进来的,它的实际路径在themes/next/layout

所以可以根据相对路径使用next主题已经实现的一些方法,例如sidebar_template

1
2
3
4
5
6
7
8
9
10
11
<!-- 标题 -->
{% extends '_layout.njk' %}
{% import '_macro/sidebar.njk' as sidebar_template with context %}

{% block content %}
<!-- 正文 -->
{% endblock %}

{% block sidebar %}
{{ sidebar_template.render(false) }}
{% endblock %}

这样的一个模板文件,就可以渲染出复合next主题的空白页面

那么下面只需要根据传入的分类树参数,渲染出分类树即可

我首先渲染出一个分类树的结构,不带博文,用于跳转到这个页面的正式分类树上

然后再渲染正式的带博文的分类树

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
{% extends '_layout.njk' %}
{% import '_macro/sidebar.njk' as sidebar_template with context %}

{% macro renderCategory(category) %}
<li style="display: inline; margin-right: 15px;">
<a href="#{{ category.name }}">{{ category.name }} <span>[{{ category.directly_posts_length }}]</span></a>
</li>
{% endmacro %}

{% macro renderPosts(category) %}
<h4 class="archive-ul show" onclick="toggleCategory('{{ category.name }}', this)">
<span class="triangle-right"></span>
{{ category.name }}
</h4>
<ul id="{{ category.name }}" class="category-content">
{% for post in category.posts %}
<li class="listing-category"><a href="{{ url_for(post.path) }}">{{ post.title }}</a></li>
{% endfor %}
{% if category.children.length %}
<ul>
{% for child in category.children %}
{{ renderPosts(child) }}
{% endfor %}
</ul>
{% endif %}
</ul>
{% endmacro %}

{% block content %}
<div class="button-container">
<button onclick="toggleAll(true)">展开全部</button>
<button onclick="toggleAll(false)">收起全部</button>
</div>

<div class="widget">
<ul class="tag_box inline list-unstyled">
{% for category in page.categoriesTree %}
{{ renderCategory(category) }}
{% endfor %}
</ul>
</div>

<div class="archive">
{% for category in page.categoriesTree %}
{{ renderPosts(category) }}
{% endfor %}
</div>
{% endblock %}

{% block sidebar %}
{{ sidebar_template.render(false) }}
{% endblock %}

展开和折叠

上文的完整模板中创建了一个按钮,用于实现展开和折叠功能

1
2
3
4
5
6
7
<h4 class="archive-ul show" onclick="toggleCategory('{{ category.name }}', this)">
<span class="triangle-right"></span>
{{ category.name }}
</h4>
<ul id="{{ category.name }}" class="category-content">
略过...
</ul>

其中triangle-right用来实现一个旋转的三角按钮,它的样式由css控制,当onclick回调被触发,将它变成.triangle-right.collapsed从而旋转90度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.triangle-right {
display: inline-block;
margin-right: 10px;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 5px solid black;
cursor: pointer;
transition: transform 0.3s;
}

.triangle-right.collapsed {
transform: rotate(90deg);
}

最后通过hexo的injector注入一段js代码,用来实现展开和折叠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
hexo.extend.injector.register('body_end', function () {
return `
<link rel="stylesheet" href="/css/custom-category.css">
<script>
//实现单个按钮的展开和折叠,通过修改categoryContent的属性来实现
function toggleCategory(categoryName, el) {
var categoryContent = document.getElementById(categoryName);
var triangle = el.querySelector('.triangle-right');
if (categoryContent.style.display === "none" || categoryContent.style.display === "") {
categoryContent.style.display = "block";
triangle.classList.remove('collapsed');
} else {
categoryContent.style.display = "none";
triangle.classList.add('collapsed');
}
}

//实现全部按钮的展开和折叠,通过修改categoryContent的属性来实现
function toggleAll(expand) {
var categories = document.querySelectorAll('.category-content');
categories.forEach(function(categoryContent) {
var triangle = categoryContent.previousElementSibling.querySelector('.triangle-right');
if (expand) {
categoryContent.style.display = 'block';
triangle.classList.remove('collapsed');
} else {
categoryContent.style.display = 'none';
triangle.classList.add('collapsed');
}
});
}
</script>
`
})

参考资料