hexo-theme-stellar 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/_config.yml +33 -10
- package/_data/icons.yml +250 -37
- package/languages/en.yml +6 -0
- package/languages/zh-CN.yml +6 -0
- package/languages/zh-TW.yml +6 -0
- package/layout/_partial/comments/artalk/script.ejs +25 -8
- package/layout/_partial/comments/beaudar/script.ejs +1 -0
- package/layout/_partial/comments/twikoo/script.ejs +1 -0
- package/layout/_partial/comments/utterances/script.ejs +1 -0
- package/layout/_partial/comments/waline/script.ejs +1 -0
- package/layout/_partial/head.ejs +43 -7
- package/layout/_partial/main/article/article_tags.ejs +16 -0
- package/layout/_partial/main/navbar/article_banner.ejs +11 -1
- package/layout/_partial/main/notebook/note_card.ejs +1 -1
- package/layout/_partial/main/notebook/paginator.ejs +2 -2
- package/layout/_partial/main/pin_slider.ejs +2 -2
- package/layout/_partial/main/post_list/paginator.ejs +2 -2
- package/layout/_partial/menubtn.ejs +2 -2
- package/layout/_partial/scripts/defines.ejs +26 -2
- package/layout/_partial/scripts.ejs +30 -7
- package/layout/_partial/sidebar/logo.ejs +1 -1
- package/layout/_partial/sidebar/menu.ejs +1 -1
- package/layout/_partial/sidebar/search.ejs +1 -1
- package/layout/_partial/widgets/author.ejs +1 -1
- package/layout/_partial/widgets/components/link.ejs +2 -2
- package/layout/_partial/widgets/ghuser.ejs +1 -1
- package/layout/_partial/widgets/related.ejs +1 -1
- package/layout/_partial/widgets/toc.ejs +1 -1
- package/layout/_partial/widgets/tree.ejs +1 -1
- package/layout/_plugins/fancybox.ejs +4 -2
- package/layout/_plugins/mermaid.ejs +2 -1
- package/layout/_plugins/search/local_search.ejs +4 -3
- package/layout/_plugins/swiper.ejs +1 -0
- package/layout/categories.ejs +1 -1
- package/layout/page.ejs +3 -0
- package/layout/tags.ejs +1 -0
- package/package.json +1 -1
- package/scripts/events/lib/doc_tree.js +12 -208
- package/scripts/events/lib/notebooks.js +8 -148
- package/scripts/events/lib/utils.js +10 -1
- package/scripts/filters/index.js +1 -0
- package/scripts/filters/lib/img_lazyload.js +4 -0
- package/scripts/filters/lib/img_onerror.js +4 -0
- package/scripts/filters/lib/md_table.js +46 -0
- package/scripts/generators/search.js +12 -18
- package/scripts/generators/stellar-icons.js +47 -0
- package/scripts/helpers/ai_label.js +29 -0
- package/scripts/helpers/icon.js +2 -2
- package/scripts/helpers/json_ld.js +10 -13
- package/scripts/helpers/related_posts.js +0 -7
- package/scripts/helpers/seo.js +9 -0
- package/scripts/lib/ai_label.js +55 -0
- package/scripts/lib/doc_tree.js +267 -0
- package/scripts/lib/notebooks.js +181 -0
- package/scripts/lib/search_index.js +82 -0
- package/scripts/lib/seo.js +79 -0
- package/scripts/tags/lib/about.js +2 -2
- package/scripts/tags/lib/albums.js +1 -1
- package/scripts/tags/lib/banner.js +1 -1
- package/scripts/tags/lib/chat.js +49 -49
- package/scripts/tags/lib/copy.js +1 -1
- package/scripts/tags/lib/friends.js +1 -1
- package/scripts/tags/lib/gallery.js +1 -1
- package/scripts/tags/lib/hashtag.js +1 -1
- package/scripts/tags/lib/image.js +3 -3
- package/scripts/tags/lib/posters.js +1 -1
- package/scripts/tags/lib/sites.js +2 -2
- package/source/css/_common/base.styl +5 -9
- package/source/css/_common/device.styl +3 -3
- package/source/css/_components/md-table.styl +23 -0
- package/source/css/_components/pages/archives.styl +2 -22
- package/source/css/_components/pages/article-story.styl +4 -4
- package/source/css/_components/partial/article-tags.styl +10 -0
- package/source/css/_components/partial/bread-nav.styl +14 -0
- package/source/css/_components/partial/navbar.styl +10 -2
- package/source/css/_components/sidebar/search.styl +24 -4
- package/source/css/_components/tag-plugins/table.styl +16 -11
- package/source/css/_components/widgets/components.styl +6 -0
- package/source/css/_components/widgets/list.styl +6 -0
- package/source/css/_components/widgets/toc.styl +1 -1
- package/source/css/_custom.styl +1 -12
- package/source/css/_defines/func.styl +28 -3
- package/source/css/_plugins/index.styl +0 -18
- package/source/css/{_plugins/comments → comments}/artalk.styl +4 -0
- package/source/css/{_plugins/comments → comments}/twikoo.styl +4 -0
- package/source/css/{_plugins → plugins}/mermaid.styl +3 -0
- package/source/css/{_plugins → plugins}/swiper.styl +5 -1
- package/source/js/icons.js +60 -0
- package/source/js/main.js +114 -0
- package/source/js/search/highlight.js +82 -0
- package/source/js/search/local-search.js +294 -103
- package/source/js/services/timeline.js +1 -1
- package/source/js/services/weibo.js +3 -3
- package/source/js/services.js +179 -0
- package/source/js/tagtree.js +29 -0
- package/source/js/theme.js +61 -0
- package/{layout/_partial/scripts/utils.ejs → source/js/utils.js} +13 -10
- package/layout/_partial/scripts/services.ejs +0 -178
- package/layout/_partial/scripts/sidebar.ejs +0 -25
- package/layout/_partial/scripts/tagtree.ejs +0 -29
- package/layout/_partial/scripts/theme.ejs +0 -67
- /package/source/css/{_plugins/comments → comments}/beaudar.styl +0 -0
- /package/source/css/{_plugins/comments → comments}/utterances.styl +0 -0
- /package/source/css/{_plugins/comments → comments}/waline.styl +0 -0
- /package/source/css/{_plugins → plugins}/fancybox.styl +0 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
8
|
const util = require('hexo-util');
|
|
9
|
+
const { postImages, postDescription } = require('../lib/seo');
|
|
9
10
|
|
|
10
11
|
hexo.extend.helper.register('json_ld', function(args) {
|
|
11
12
|
const page = this.page;
|
|
@@ -37,6 +38,13 @@ hexo.extend.helper.register('json_ld', function(args) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
if (this.is_post()) {
|
|
41
|
+
const images = postImages({
|
|
42
|
+
cover: page.cover,
|
|
43
|
+
banner: page.banner,
|
|
44
|
+
photos: page.photos,
|
|
45
|
+
content: page.content,
|
|
46
|
+
defaultCover: this.theme.default && this.theme.default.cover
|
|
47
|
+
});
|
|
40
48
|
schema = {
|
|
41
49
|
'@context': 'https://schema.org',
|
|
42
50
|
'@type': 'BlogPosting',
|
|
@@ -45,7 +53,7 @@ hexo.extend.helper.register('json_ld', function(args) {
|
|
|
45
53
|
dateCreated: page.date.format(),
|
|
46
54
|
dateModified: page.updated.format(),
|
|
47
55
|
datePublished: page.date.format(),
|
|
48
|
-
description:
|
|
56
|
+
description: postDescription({ excerpt: page.excerpt, content: page.content }),
|
|
49
57
|
headline: page.title,
|
|
50
58
|
mainEntityOfPage: {
|
|
51
59
|
'@type': 'WebPage',
|
|
@@ -59,18 +67,7 @@ hexo.extend.helper.register('json_ld', function(args) {
|
|
|
59
67
|
schema.keywords = page.tags.map((tag) => tag.name).join(', ');
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
if (page.photos && page.photos.length > 0) {
|
|
64
|
-
images = images.concat(page.photos);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (page.cover?.length > 0) {
|
|
68
|
-
images = images.unshift(page.cover);
|
|
69
|
-
} else if (page.banner?.length > 0) {
|
|
70
|
-
images = images.unshift(page.banner);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
schema.thumbnailUrl = page.cover || page.banner;
|
|
70
|
+
schema.thumbnailUrl = images[0] || undefined;
|
|
74
71
|
schema.image = images;
|
|
75
72
|
|
|
76
73
|
} else if (isPage || this.is_home()) {
|
|
@@ -27,16 +27,9 @@ hexo.extend.helper.register('popular_posts_wrapper', function(args){
|
|
|
27
27
|
<section class='body'>
|
|
28
28
|
`;
|
|
29
29
|
|
|
30
|
-
const posts = this.site.posts;
|
|
31
|
-
const root = this.config.root;
|
|
32
|
-
|
|
33
30
|
function listItem(obj){
|
|
34
31
|
var el = '';
|
|
35
32
|
el += '<a class="item" href="' + esc(obj.path) + '" title="' + esc(obj.title) + '">';
|
|
36
|
-
var p = posts.filter(p => root + p.path == obj.path)
|
|
37
|
-
if (p && p.length > 0) {
|
|
38
|
-
p = p.data[0];
|
|
39
|
-
}
|
|
40
33
|
el += '<span class="title">' + esc(obj.title) + '</span>';
|
|
41
34
|
if (obj.excerpt && obj.excerpt.length > 0) {
|
|
42
35
|
el += '<span class="excerpt">' + esc(util.truncate(util.stripHTML(obj.excerpt), {length: 120})) + '</span>';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { escapeHTML } = require('hexo-util');
|
|
4
|
+
|
|
5
|
+
// 文章 AI 成分标签(ai_label):颜色规范化 + 标签 HTML 生成。
|
|
6
|
+
// 文案由调用方传入(多语言系统提供),经 HTML 转义后输出;
|
|
7
|
+
// 文字使用配置色(无背景),缺 # 自动补全;可选 iconHtml 渲染在文案前;
|
|
8
|
+
// noColor 时输出不带内联样式(继承默认文字色,用于 banner 含图片场景);
|
|
9
|
+
// 样式由调用方通过 extraClass 传入上下文类。
|
|
10
|
+
|
|
11
|
+
function normalizeColor(color) {
|
|
12
|
+
if (color == null) {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
let text = String(color).trim();
|
|
16
|
+
if (!text) {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
if (!text.startsWith('#')) {
|
|
20
|
+
text = '#' + text;
|
|
21
|
+
}
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 解析实际生效的标签键:文章设置 ai_label 时优先使用,否则取配置 default(为空则不渲染)
|
|
26
|
+
function resolveAiKey(value, labelConfig) {
|
|
27
|
+
if (value && typeof value === 'string') {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return (labelConfig && labelConfig.default) || '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildAiLabel(value, labelConfig, text, extraClass, iconHtml, noColor) {
|
|
34
|
+
if (!value || typeof value !== 'string' || !labelConfig || typeof labelConfig !== 'object') {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
const def = labelConfig[value];
|
|
38
|
+
if (!def || typeof def !== 'object' || !text) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
const cls = ['ai-label'];
|
|
42
|
+
if (extraClass) {
|
|
43
|
+
cls.unshift(extraClass);
|
|
44
|
+
}
|
|
45
|
+
const safeText = escapeHTML(String(text));
|
|
46
|
+
const color = normalizeColor(def.color);
|
|
47
|
+
const icon = iconHtml || '';
|
|
48
|
+
const className = cls.join(' ');
|
|
49
|
+
if (!noColor && color) {
|
|
50
|
+
return `<span class="${className}" style="color:${color}">${icon}${safeText}</span>`;
|
|
51
|
+
}
|
|
52
|
+
return `<span class="${className}">${icon}${safeText}</span>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { buildAiLabel, normalizeColor, resolveAiKey };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc_tree.js v2 | https://github.com/xaoxuu/hexo-theme-stellar/
|
|
3
|
+
*
|
|
4
|
+
* Wiki 文档树构建(纯函数,供 events/lib/doc_tree.js 调用与单测覆盖)。
|
|
5
|
+
* 与旧实现的输出语义保持一致,仅把重复的 filter/some 遍历改为单遍 Map 索引:
|
|
6
|
+
* - 页面按 wiki 分组一次(O(W+P) 替代 O(W*P));
|
|
7
|
+
* - 项目内按 path_key 分组一次,首页解析与 sections 组装改走索引查询;
|
|
8
|
+
* - all_tags / relatedItems 用 Set/Map 去重,输出顺序不变。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { normalize_path } = require('./path_utils');
|
|
14
|
+
|
|
15
|
+
class WikiPage {
|
|
16
|
+
constructor(page) {
|
|
17
|
+
this.id = page._id;
|
|
18
|
+
this.wiki = page.wiki;
|
|
19
|
+
this.title = page.title;
|
|
20
|
+
this.path = page.path;
|
|
21
|
+
this.path_key = normalize_path(page.path);
|
|
22
|
+
this.layout = page.layout;
|
|
23
|
+
this.updated = page.updated;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getWikiObject(data) {
|
|
28
|
+
var wiki = { tree: {} };
|
|
29
|
+
var list = [];
|
|
30
|
+
for (let key of Object.keys(data)) {
|
|
31
|
+
if (key.endsWith('.DS_Store')) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (key.includes('wiki/') && key.length > 5) {
|
|
35
|
+
let newKey = key.replace('wiki/', '');
|
|
36
|
+
let obj = data[key];
|
|
37
|
+
if ((typeof obj.tags == 'string') && obj.tags.constructor == String) {
|
|
38
|
+
obj.tags = [obj.tags];
|
|
39
|
+
}
|
|
40
|
+
if ((typeof obj.tree == 'object') && obj.tree.constructor == Array) {
|
|
41
|
+
obj.tree = { '': obj.tree };
|
|
42
|
+
}
|
|
43
|
+
obj.id = newKey;
|
|
44
|
+
if (obj.sort == null) {
|
|
45
|
+
obj.sort = 0;
|
|
46
|
+
}
|
|
47
|
+
if (obj.base_dir) {
|
|
48
|
+
if (obj.base_dir.startsWith('/')) {
|
|
49
|
+
obj.base_dir = obj.base_dir.substring(1);
|
|
50
|
+
}
|
|
51
|
+
if (obj.base_dir.length > 1 && obj.base_dir.endsWith('/') == false) {
|
|
52
|
+
obj.base_dir = obj.base_dir + '/';
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
obj.base_dir = '';
|
|
56
|
+
}
|
|
57
|
+
list.push(obj);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
list = list.sort((p1, p2) => p2.sort - p1.sort);
|
|
61
|
+
for (let item of list) {
|
|
62
|
+
wiki.tree[item.id] = item;
|
|
63
|
+
}
|
|
64
|
+
return wiki;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 构建 wiki 文档树。
|
|
69
|
+
* @param {Object} options
|
|
70
|
+
* @param {Object} options.data 站点 _data(含 wiki/ 前缀的 YAML 数据)
|
|
71
|
+
* @param {Array|Object} options.pages 站点 pages(warehouse Query 或数组)
|
|
72
|
+
* @param {Array} options.shelf 上架项目 id 列表
|
|
73
|
+
* @param {Object} options.siteTree 主题配置 site_tree(取 index_wiki.base_dir)
|
|
74
|
+
*/
|
|
75
|
+
function buildWikiTree({ data, pages, shelf, siteTree }) {
|
|
76
|
+
// wiki 配置
|
|
77
|
+
var wiki = getWikiObject(data);
|
|
78
|
+
// wiki 所有页面
|
|
79
|
+
const wiki_pages = pages.filter(p => (p.wiki != null)).map(p => new WikiPage(p));
|
|
80
|
+
|
|
81
|
+
// 单遍分组:wiki id → WikiPage[](保持 wiki_pages 原始顺序)
|
|
82
|
+
const pagesByWiki = new Map();
|
|
83
|
+
for (const wp of wiki_pages) {
|
|
84
|
+
let arr = pagesByWiki.get(wp.wiki);
|
|
85
|
+
if (arr == null) {
|
|
86
|
+
arr = [];
|
|
87
|
+
pagesByWiki.set(wp.wiki, arr);
|
|
88
|
+
}
|
|
89
|
+
arr.push(wp);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 上架的项目列表
|
|
93
|
+
wiki.shelf = shelf || [];
|
|
94
|
+
|
|
95
|
+
// 按 tree 键序过滤出有页面的项目(等价于旧实现 wiki_pages.some 判定)
|
|
96
|
+
const wiki_list = Object.keys(wiki.tree).filter(id => pagesByWiki.has(id));
|
|
97
|
+
|
|
98
|
+
// 数据整合:项目标签
|
|
99
|
+
var all_tag_name = [];
|
|
100
|
+
for (let id of wiki_list) {
|
|
101
|
+
let item = wiki.tree[id];
|
|
102
|
+
let tags = item.tags;
|
|
103
|
+
if (tags) {
|
|
104
|
+
tags.forEach((tag, i) => {
|
|
105
|
+
if (all_tag_name.includes(tag) === false) {
|
|
106
|
+
all_tag_name.push(tag);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
wiki.tree[id].tags = tags;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 补充项目名称和首页
|
|
114
|
+
for (let id of wiki_list) {
|
|
115
|
+
let item = wiki.tree[id];
|
|
116
|
+
item.id = id;
|
|
117
|
+
if (item.title == undefined || item.title.length === 0) {
|
|
118
|
+
item.title = id;
|
|
119
|
+
}
|
|
120
|
+
if (item.name == undefined || item.name.length == 0) {
|
|
121
|
+
item.name = id;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 数据整合:每个项目的子页面
|
|
126
|
+
for (let i = 0; i < wiki_list.length; i++) {
|
|
127
|
+
let id = wiki_list[i];
|
|
128
|
+
let item = wiki.tree[id];
|
|
129
|
+
let sub_pages = pagesByWiki.get(id) || [];
|
|
130
|
+
|
|
131
|
+
// 单遍 path_key 索引:同一 path_key 可能对应多个页面,保持数组与顺序
|
|
132
|
+
const pagesByPathKey = new Map();
|
|
133
|
+
for (const p of sub_pages) {
|
|
134
|
+
let arr = pagesByPathKey.get(p.path_key);
|
|
135
|
+
if (arr == null) {
|
|
136
|
+
arr = [];
|
|
137
|
+
pagesByPathKey.set(p.path_key, arr);
|
|
138
|
+
}
|
|
139
|
+
arr.push(p);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 首页
|
|
143
|
+
// 未特别指定首页时,获取TOC第一页作为首页
|
|
144
|
+
var homepage = item.homepage;
|
|
145
|
+
if (homepage == null && item.tree != null) {
|
|
146
|
+
for (let tid of Object.keys(item.tree)) {
|
|
147
|
+
const sec = item.tree[tid];
|
|
148
|
+
for (let key of sec) {
|
|
149
|
+
const hs = pagesByPathKey.get(normalize_path(item.base_dir + key)) || [];
|
|
150
|
+
if (hs.length > 0) {
|
|
151
|
+
homepage = hs[0];
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (homepage != null) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (homepage == null) {
|
|
161
|
+
homepage = sub_pages[0];
|
|
162
|
+
}
|
|
163
|
+
if (typeof homepage == 'string') {
|
|
164
|
+
homepage = { path: homepage };
|
|
165
|
+
}
|
|
166
|
+
homepage.is_homepage = true;
|
|
167
|
+
item.homepage = homepage;
|
|
168
|
+
|
|
169
|
+
// 内页分组
|
|
170
|
+
var sections = [];
|
|
171
|
+
var others = sub_pages;
|
|
172
|
+
if (item.tree) {
|
|
173
|
+
// 根据配置设置顺序
|
|
174
|
+
const assignedPathKeys = new Set();
|
|
175
|
+
for (let title of Object.keys(item.tree)) {
|
|
176
|
+
var sec = { title: title, pages: [] };
|
|
177
|
+
for (let key of item.tree[title]) {
|
|
178
|
+
const pagePathKey = normalize_path(item.base_dir + key);
|
|
179
|
+
const matched = pagesByPathKey.get(pagePathKey) || [];
|
|
180
|
+
sec.pages = sec.pages.concat(matched);
|
|
181
|
+
if (matched.length > 0) {
|
|
182
|
+
assignedPathKeys.add(pagePathKey);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
sections.push(sec);
|
|
186
|
+
}
|
|
187
|
+
// 未被任何 section 收录的页面归入 '...' 分组(顺序同 sub_pages 过滤结果)
|
|
188
|
+
others = sub_pages.filter(p => !assignedPathKeys.has(p.path_key));
|
|
189
|
+
if (others.length > 0 && others.filter(p => p.title?.length > 0).length > 0) {
|
|
190
|
+
sections.push({
|
|
191
|
+
title: '...',
|
|
192
|
+
pages: others.sort((p1, p2) => p1.title - p2.title)
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
// 自动设置顺序
|
|
197
|
+
sections.push({
|
|
198
|
+
pages: sub_pages.sort((p1, p2) => p1.title - p2.title)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// page number
|
|
203
|
+
var page_number = 0;
|
|
204
|
+
for (let sec of sections) {
|
|
205
|
+
for (let page of sec.pages) {
|
|
206
|
+
page.page_number = page_number++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
item.sections = sections;
|
|
210
|
+
item.pages = sub_pages;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 全站所有的项目标签
|
|
214
|
+
var all_tags = {};
|
|
215
|
+
all_tag_name.forEach((tag_name, i) => {
|
|
216
|
+
var items = [];
|
|
217
|
+
// 与旧实现 items.includes(tag_name) 判定等价(项目 id 恰等于标签名时的去重行为)
|
|
218
|
+
var itemsSet = new Set();
|
|
219
|
+
for (let id of wiki_list) {
|
|
220
|
+
let item = wiki.tree[id];
|
|
221
|
+
// 过滤掉找不到页面的项目
|
|
222
|
+
if (item.homepage == null) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// 过滤掉未上架的项目
|
|
226
|
+
if (!wiki.shelf.includes(item.id)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (item.tags && item.tags.includes(tag_name) === true && itemsSet.has(tag_name) === false) {
|
|
230
|
+
itemsSet.add(item.id);
|
|
231
|
+
items.push(item.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
all_tags[tag_name] = {
|
|
235
|
+
name: tag_name,
|
|
236
|
+
path: (siteTree.index_wiki.base_dir) + '/tags/' + tag_name + '/index.html',
|
|
237
|
+
items: items
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// 关联相似项目
|
|
242
|
+
for (let id of wiki_list) {
|
|
243
|
+
let item = wiki.tree[id];
|
|
244
|
+
if (item.tags) {
|
|
245
|
+
var relatedItems = [];
|
|
246
|
+
item.tags.forEach((tag_name, i) => {
|
|
247
|
+
let relatedOtherItems = all_tags[tag_name].items.filter(name => name != item.id);
|
|
248
|
+
if (relatedOtherItems.length > 0) {
|
|
249
|
+
relatedItems.push({
|
|
250
|
+
name: tag_name,
|
|
251
|
+
items: relatedOtherItems
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
item.relatedItems = relatedItems;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
wiki.all_tags = all_tags;
|
|
260
|
+
wiki.all_pages = wiki_pages;
|
|
261
|
+
return wiki;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = {
|
|
265
|
+
buildWikiTree,
|
|
266
|
+
WikiPage
|
|
267
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notebooks.js v1
|
|
3
|
+
*
|
|
4
|
+
* 笔记本系统构建(纯函数部分,供 events/lib/notebooks.js 调用与单测覆盖)。
|
|
5
|
+
* 把「每个笔记本全量 filter 一遍全部页面」改为单遍分组(O(NB+P) 替代 O(NB*P)),
|
|
6
|
+
* 标签树与 noteMap 构建逻辑和元素顺序不变。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { normalize_path } = require('./path_utils');
|
|
12
|
+
|
|
13
|
+
class NotePage {
|
|
14
|
+
constructor(page) {
|
|
15
|
+
this.id = page._id;
|
|
16
|
+
this.notebook = page.notebook;
|
|
17
|
+
this.title = page.title;
|
|
18
|
+
this.tags = page.tags;
|
|
19
|
+
this.path = page.path;
|
|
20
|
+
this.path_key = normalize_path(page.path);
|
|
21
|
+
this.layout = page.layout;
|
|
22
|
+
this.date = page.date;
|
|
23
|
+
this.updated = page.updated || page.date;
|
|
24
|
+
|
|
25
|
+
const pin = page.pin ?? page.sticky ?? 0;
|
|
26
|
+
if (pin === true) {
|
|
27
|
+
this.pin = 1;
|
|
28
|
+
} else if (pin === false) {
|
|
29
|
+
this.pin = 0;
|
|
30
|
+
} else {
|
|
31
|
+
this.pin = pin;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function splitTag(tag) {
|
|
37
|
+
return tag.split('/').filter(t => t.length > 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 单遍按 notebook id 分组页面。
|
|
42
|
+
* 等价的旧逻辑:对每个笔记本执行 allPages.filter(p => p.notebook === notebook.id)。
|
|
43
|
+
* @param {Array} pages 页面数组(warehouse Query 的 .data)
|
|
44
|
+
* @returns {Map<string, Array>}
|
|
45
|
+
*/
|
|
46
|
+
function groupPagesByNotebook(pages) {
|
|
47
|
+
const pagesByNotebook = new Map();
|
|
48
|
+
for (const page of pages) {
|
|
49
|
+
if (page.notebook == null) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let arr = pagesByNotebook.get(page.notebook);
|
|
53
|
+
if (arr == null) {
|
|
54
|
+
arr = [];
|
|
55
|
+
pagesByNotebook.set(page.notebook, arr);
|
|
56
|
+
}
|
|
57
|
+
arr.push(page);
|
|
58
|
+
}
|
|
59
|
+
return pagesByNotebook;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function prepareNotebook(id, info, ctx, pages) {
|
|
63
|
+
const notebook = info;
|
|
64
|
+
notebook.id = id;
|
|
65
|
+
|
|
66
|
+
if (notebook.base_dir) {
|
|
67
|
+
if (notebook.base_dir.startsWith('/')) {
|
|
68
|
+
notebook.base_dir = notebook.base_dir.substring(1);
|
|
69
|
+
}
|
|
70
|
+
if (notebook.base_dir.length > 1 && !notebook.base_dir.endsWith('/')) {
|
|
71
|
+
notebook.base_dir = notebook.base_dir + '/';
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
const notebooksBaseDir = ctx.theme.config.site_tree.notebooks.base_dir;
|
|
75
|
+
notebook.base_dir = notebooksBaseDir ? `${notebooksBaseDir}/${id}` : id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
notebook.sort ||= 0;
|
|
79
|
+
notebook.auto_excerpt ||= ctx.theme.config.notebook.auto_excerpt || 0;
|
|
80
|
+
notebook.per_page ??= ctx.theme.config.notebook.per_page ?? ctx.config.per_page ?? 10;
|
|
81
|
+
notebook.order_by ||= ctx.theme.config.notebook.order_by || '-updated';
|
|
82
|
+
notebook.menu_id ??= ctx.theme.config.site_tree.notes.menu_id;
|
|
83
|
+
notebook.license ??= ctx.theme.config.notebook.license;
|
|
84
|
+
notebook.share ??= ctx.theme.config.notebook.share;
|
|
85
|
+
|
|
86
|
+
notebook.leftbar ??= ctx.theme.config.site_tree.notes.leftbar;
|
|
87
|
+
notebook.rightbar ??= ctx.theme.config.site_tree.notes.rightbar;
|
|
88
|
+
notebook.note_leftbar ??= ctx.theme.config.site_tree.note.leftbar;
|
|
89
|
+
notebook.note_rightbar ??= ctx.theme.config.site_tree.note.rightbar;
|
|
90
|
+
|
|
91
|
+
const tagMap = new Map(); // tagId: tagInfo
|
|
92
|
+
notebook.tagTree = tagMap;
|
|
93
|
+
|
|
94
|
+
const rootTag = {
|
|
95
|
+
id: '',
|
|
96
|
+
name: '',
|
|
97
|
+
part: '',
|
|
98
|
+
path: notebook.base_dir,
|
|
99
|
+
parent: null, // parent tag id
|
|
100
|
+
childSet: new Set(), // child tag ids
|
|
101
|
+
noteSet: new Set(), // note ids
|
|
102
|
+
};
|
|
103
|
+
tagMap.set(rootTag.id, rootTag);
|
|
104
|
+
|
|
105
|
+
// Iterate through all notes in the notebook, build the tag tree.
|
|
106
|
+
for (const page of pages) {
|
|
107
|
+
rootTag.noteSet.add(page._id);
|
|
108
|
+
|
|
109
|
+
if (!page.tags) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const hierarchyTag of page.tags) {
|
|
114
|
+
const parts = splitTag(hierarchyTag);
|
|
115
|
+
let parent = rootTag;
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
const tagName = parent.name ? `${parent.name}/${part}` : part;
|
|
118
|
+
const tagId = tagName.toLowerCase();
|
|
119
|
+
let tag = tagMap.get(tagId);
|
|
120
|
+
if (tag == null) {
|
|
121
|
+
tag = {
|
|
122
|
+
id: tagId,
|
|
123
|
+
name: tagName,
|
|
124
|
+
part: part,
|
|
125
|
+
path: `${notebook.base_dir}/tags/${tagId}`,
|
|
126
|
+
parent: parent.id,
|
|
127
|
+
childSet: new Set(),
|
|
128
|
+
noteSet: new Set(),
|
|
129
|
+
};
|
|
130
|
+
tagMap.set(tagId, tag);
|
|
131
|
+
parent.childSet.add(tagId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tag.noteSet.add(page._id);
|
|
135
|
+
parent = tag;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
notebook.noteMap = pages.map(p => new NotePage(p)).reduce((map, note) => {
|
|
141
|
+
map.set(note.id, note);
|
|
142
|
+
return map;
|
|
143
|
+
}, new Map());
|
|
144
|
+
|
|
145
|
+
// Sort child tags for each tag.
|
|
146
|
+
for (const [_, tag] of tagMap) {
|
|
147
|
+
tag.children = Array.from(tag.childSet);
|
|
148
|
+
tag.children.sort();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return notebook;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getNotebooksObject(ctx) {
|
|
155
|
+
const notebooks = {
|
|
156
|
+
tree: {},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const data = ctx.locals.get('data');
|
|
160
|
+
const pagesByNotebook = groupPagesByNotebook(ctx.locals.get('pages').data);
|
|
161
|
+
const list = [];
|
|
162
|
+
for (const [key, info] of Object.entries(data)) {
|
|
163
|
+
if (!key.startsWith('notebooks/') || key.endsWith('.DS_Store')) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const id = key.substring(10);
|
|
167
|
+
list.push(prepareNotebook(id, info, ctx, pagesByNotebook.get(id) || []));
|
|
168
|
+
}
|
|
169
|
+
list.sort((a, b) => a.sort - b.sort);
|
|
170
|
+
for (const info of list) {
|
|
171
|
+
notebooks.tree[info.id] = info;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return notebooks;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
groupPagesByNotebook,
|
|
179
|
+
NotePage,
|
|
180
|
+
getNotebooksObject
|
|
181
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 本地搜索索引正文构建:从渲染后 HTML 提取搜索正文与章节锚点。
|
|
5
|
+
*
|
|
6
|
+
* - 返回的 content 与 scripts/generators/search.js 原有全文归一化结果逐字节一致;
|
|
7
|
+
* - anchors 每项 { id, text, offset },offset 指向标题文本在 content 中的起始位置,
|
|
8
|
+
* 与客户端 content.indexOf() 的偏移严格对齐。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { stripHTML } = require('hexo-util');
|
|
12
|
+
|
|
13
|
+
// 标题标签切分:捕获 level / 开始标签属性 / 内部 HTML
|
|
14
|
+
const HEADING_RE = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
|
|
15
|
+
const HEADING_ID_RE = /id="([^"]*)"/;
|
|
16
|
+
|
|
17
|
+
// 全局预清理:与 scripts/generators/search.js 原有顺序保持一致(先于 stripHTML)
|
|
18
|
+
function preClean(html) {
|
|
19
|
+
return String(html || '')
|
|
20
|
+
.replace(/<span class="line">\d+<\/span>/g, '')
|
|
21
|
+
.replace(/<iframe[\s|\S]+iframe>/g, '')
|
|
22
|
+
.replace(/<hr>/g, '')
|
|
23
|
+
.replace(/<br>/g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 单段清洗:stripHTML 之后移除实体、换行转空格、空白折叠(不做全文 trim)
|
|
27
|
+
function cleanChunk(chunk) {
|
|
28
|
+
return stripHTML(chunk)
|
|
29
|
+
.replace(/&[^\s;]+;/g, '')
|
|
30
|
+
.replace(/\\n/g, ' ')
|
|
31
|
+
.replace(/\n/g, ' ')
|
|
32
|
+
.replace(/[\s]{2,}/g, ' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 跨段拼接:上一段以空白结尾且本段以空白开头时,合并为单个空白,
|
|
36
|
+
// 与整段式 [\s]{2,} 折叠在边界上的结果一致
|
|
37
|
+
function mergeChunk(buffer, chunk) {
|
|
38
|
+
if (buffer && /\s$/.test(buffer) && /^\s/.test(chunk)) {
|
|
39
|
+
return chunk.replace(/^\s+/, '');
|
|
40
|
+
}
|
|
41
|
+
return chunk;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 构建搜索索引条目:{ content, anchors }
|
|
46
|
+
* @param {string} html 渲染后的页面 HTML
|
|
47
|
+
*/
|
|
48
|
+
function buildSearchIndex(html) {
|
|
49
|
+
const parts = preClean(html).split(HEADING_RE);
|
|
50
|
+
let content = '';
|
|
51
|
+
const anchors = [];
|
|
52
|
+
for (let i = 0; i < parts.length; i += 4) {
|
|
53
|
+
const before = parts[i] || '';
|
|
54
|
+
const level = parts[i + 1];
|
|
55
|
+
const attrs = parts[i + 2] || '';
|
|
56
|
+
const inner = parts[i + 3] || '';
|
|
57
|
+
content += mergeChunk(content, cleanChunk(before));
|
|
58
|
+
if (!level) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const rawText = cleanChunk(inner);
|
|
62
|
+
const text = rawText.trim();
|
|
63
|
+
const merged = mergeChunk(content, rawText);
|
|
64
|
+
const appended = merged.slice(content.length);
|
|
65
|
+
const offset = content.length + (appended.length - appended.trimStart().length);
|
|
66
|
+
const match = attrs.match(HEADING_ID_RE);
|
|
67
|
+
if (match && text.length > 0) {
|
|
68
|
+
anchors.push({ id: match[1], text, offset });
|
|
69
|
+
}
|
|
70
|
+
content += merged;
|
|
71
|
+
}
|
|
72
|
+
const trimmed = content.trim();
|
|
73
|
+
const shift = content.length - content.trimStart().length;
|
|
74
|
+
if (shift > 0) {
|
|
75
|
+
for (const anchor of anchors) {
|
|
76
|
+
anchor.offset -= shift;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { content: trimmed, anchors };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { buildSearchIndex };
|