VitePress 扩展首页内容(旧)
许多的自定义主题中都有提取页面并以列表形式展示在首页的功能,例如配置解析-主题配置中介绍的vitepress-blog-zaun
在 VitePress 1.0 之前的实现思路大致是通过 NodeJS 在构建阶段读取项目中的 md 文件,获取关键信息后存储在 .vitepress/config
配置选项 themeConfig 中,再利用存储的信息自定义首页的样式
利用这些信息,我们可以实现:
- 在首页自定义展示文章列表
- 实现随机跳转一篇文章按钮
- 展示项目首次提交距今时长以及发文总数
- 文章
frontmatter
中添加 tag 标签,实现 tag 分类的效果(本站未实现,可以自行尝试) - ......
useData
官方提供了 useData() API
,可以读取页面以及项目关键信息(这也是为什么要存储在配置对象中的原因),能够获取的信息包括:
interface VitePressData<T = any> {
site: Ref<SiteData<T>>;
page: Ref<PageData>;
theme: Ref<T>; // 定义在.vitepress/config中的themeConfig对象
frontmatter: Ref<PageData["frontmatter"]>;
title: Ref<string>;
description: Ref<string>;
lang: Ref<string>;
isDark: Ref<boolean>;
dir: Ref<string>;
localeIndex: Ref<string>;
}
2
3
4
5
6
7
8
9
10
11
12
定义提取脚本
利用NodeJS
提供的读取文件/文件夹API
,可以遍历出所有md
文件路径(或根据sidebar
获取);再循环读取文件的内容,解析出关键信息存储,并利用git log
命令提供的获取提交时间功能,扩展文件创建、更新时间,就完整的得到了所需要的信息
遍历文件用到了globby库,内部封装了遍历逻辑,可以便捷提取需要的md
文件
解析md
文件用到了gray-matter库,这也是VitePress
中使用的解析工具,能够提取出文件头部的FrontMatter
信息和文章内容
获取页面提交时间参考自 VitePress 内获取页面最后更新时间的代码src/node/utils/getGitTimestamp.ts
提示
本文档创作过程中发现不能在config
配置中含有例如import.meta.env
等部分字符串,在向官方提issue后得到回复这并不算一个bug
,建议通过\0
插入空字符解决
- 创建脚本文件
.vitepress/readPages.ts
:
脚本代码
import { globby } from "globby";
import matter from "gray-matter";
import { readFile } from "fs/promises";
import { type DefaultTheme } from "vitepress";
import { spawn } from "child_process";
interface ReadOption {
ignorePath?: string[];
sidebar?: DefaultTheme.Sidebar;
path?: string;
}
export interface Pages {
frontMatter: Record<string, any>;
path: string;
content: string;
title: string;
}
export default async function readPages(
option: ReadOption = {}
): Promise<Pages[]> {
// 获取需要的页面路径
let paths = await getPagePaths(option);
let pages = await Promise.all(
// 循环获取页面信息
paths.map(async (item) => {
// 获取首次提交和最后提交时间
const date = await getGitTimestamp(item);
// 读取并解析页面内容
const file = await readFile(item, { encoding: "utf-8" });
const { data, content } = matter(file);
data.date = date;
const path = item.replace(".md", "");
return {
frontMatter: data,
path,
content: content
// 处理会被vitepress识别的特殊关键字
.replace(/(import.meta)/gi, "i\0mport.meta")
.replace(/(process.env)/gi, "p\0rocess.env")
.replace(/(__CARBON__)/gi, "__A\0LGOLIA__")
.replace(/(__CARBON__)/gi, "__C\0ARBON__")
// 只保留最大可能显示范围
.slice(0, 500),
title: path.split("/").pop() || path, // 从路径中获取标题
};
})
);
// 按发布日期降序排列
pages.sort((a, b) => b.frontMatter.date[0] - a.frontMatter.date[0]);
return pages;
}
async function getPagePaths(option: ReadOption) {
// 如果传入了sidebar,则获取sidebar中所有页面路径传入globby
// 否则遍历所有md文件
const patterns: string[] = option.sidebar
? getLink(option.sidebar, option.path)
: ["**.md"];
// 忽略无需识别的文件
return await globby(patterns, {
ignore: [
"node_modules",
"README.md",
"public",
".vitepress",
"components",
"scripts",
...(option.ignorePath || []),
],
});
}
// 使用正则提取sidebar中所有页面链接
function getLink(
sidebar: DefaultTheme.Sidebar,
path: string = "/docs"
): string[] {
const result: string[] = [];
const regex = /"link":"([^"]*)"/g;
let matches: RegExpExecArray | null;
while ((matches = regex.exec(JSON.stringify(sidebar))) !== null) {
result.push(`${path}/${matches[1]}.md`);
}
return result;
}
// 获取文件提交时间
function getGitTimestamp(file: string) {
return new Promise<[number, number]>((resolve, reject) => {
// 开启子进程执行git log命令
const child = spawn("git", ["--no-pager", "log", '--pretty="%ci"', file]);
let output: string[] = [];
child.stdout.on("data", (d) => {
const data = String(d).trim();
data && (output = data.split("\n"));
});
child.on("close", () => {
if (output.length) {
// 返回[发布时间,最近更新时间]
resolve([+new Date(output[output.length - 1]), +new Date(output[0])]);
} else {
// 没有提交记录的文件,返回当前时间
resolve([+new Date(), +new Date()]);
}
});
child.on("error", reject);
});
}
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
- 在
.vitepress/config.ts
中加入以下代码:
import { DefaultTheme, UserConfig } from 'vitepress'
import sidebar from './sidebar'
import readPages, { getGitTimestamp } from './readPages'
async function config() {
return {
...
themeConfig: {
// 添加页面信息,可以传入对象参数:
// ignorePath?: string[] 忽略读取的路径
// sidebar?: Sidebar sidebar对象
// path?: string 文档主目录,默认/docs
pages: await readPages({ sidebar }),
...
},
...
} as UserConfig<DefaultTheme.Config>
}
export default config()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
自定义首页
创建components/Home.vue
文件,并修改index.md
:
---
layout: home
---
<script setup>
import Home from './components/Home.vue'
</script>
<Home />
2
3
4
5
6
7
8
9
单独抽离 Vue 文件主要是为了使用代码提示等编辑器功能
本站直接复用了VitePress
默认主题中的组件来实现功能,你完全可以自己设计首页组件
实现文章列表
从pages
数据中很轻易能够生成文章列表,但有几个值得注意的点:
- 文章
md
文件内的标题和nav
中的标题不一定一致,在文章列表中更适合提取md
文件内定义的标题 - 可能存在标题是
FrontMatter
变量的情况,这时候需要提取变量名后替换实际标题 - 如果直接用文章内容作为简介,需要对
md
标签做处理
<script setup>
...
const features = ref<Feature[]>(
pages.map((item) => {
// 用页面的一级标题作为文章标题(因为sidebar中可能是精简的标题)
let regTitle = item.content.match(/^# (\S+?)\s*$/m)?.[1]
// 标题可能用到了变量,需要替换
const matterTitle = regTitle?.match(/\{\{\$frontmatter\.(\S+)\}\}/)?.[1]
if (matterTitle) {
regTitle = item.frontMatter[matterTitle]
}
return {
title: regTitle || item.title,
details: item.content
// 去除html标签
.replace(/<[^>]+?>/g, '')
// 去除标题
.replace(/^#+ [\S]+?\s/gm, '')
// 去除引用
.replace(/^\> /gm, '')
// 只保留反引号内部内容
.replace(/`(\S+?)`/g, '$1')
// 只保留加粗、倾斜符号中的内容
.replace(/\*{1,3}(\S+?)\*{1,3}/g, '$1')
// 只保留跳转内容
.replace(/\[(\S+?)\]\(\S+?\)/g, '$1')
// 去除提示块
.replace(/^:::[\s\S]+?$/gm, '')
// 去除空白字符
.replace(/\s/g, ' '),
link: item.path,
// 显示发布时间
linkText: dayjs(item.frontMatter.date[0]).format('YYYY-MM-DD'),
}
})
)
</script>
<style scoped>
/* 定义简介内容2行省略 */
:deep(.details) {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
</style>
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
实现这个功能后还发现了有意思的一点:
vitepress
中VPFeatures
组件只处理了条数为2
、3
倍数的情况,所以像我直接拿来做文章列表其实是有一点问题的,在文章数不是2
、3
倍数的时候就只能一篇文章一行的显示
// vitepress\dist\client\theme-default\components\VPFeatures.vue
const grid = computed(() => {
const length = props.features.length;
if (!length) {
return;
} else if (length === 2) {
return "grid-2";
} else if (length === 3) {
return "grid-3";
} else if (length % 3 === 0) {
return "grid-6";
} else if (length % 2 === 0) {
return "grid-4";
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
另外VPFeature
组件展示内容是使用的v-html
,所以需要处理内容中的HTML
标签
实现随机跳转
定义获取0 至 总篇数的随机数便可以实现随机跳转,因为文档跳转都是在页面内跳转,也无需处理重新生成随机数的问题(跳转返回首页后随机函数会重新执行)
const actions = ref([
{
text: "随便逛逛",
link: randomPage(),
},
]);
function randomPage(): string {
const length = pages.length - 1;
return pages[Math.floor(Math.random() * length)].path;
}
2
3
4
5
6
7
8
9
10
11
显示项目运行时长与总篇数
使用了Dayjs格式化时间,再通过setInterval
每秒更新即可
需要注意的是setInterval
相关逻辑需要写在onMounted
中
因为VitePress
使用SSG (Static Site Generation)
渲染,所以在构建过程中会完整的执行项目内属于SSG
流程的逻辑,包括setInterval
。这就会导致定时器得不到回收,从而阻塞打包进程(表现为打包完成,但终端任务不会结束)
// 第一篇文章的发布时间作为建站时间
const firstCommit: number = pages[pages.length - 1].frontMatter.date[0];
// 传入Hero组件的tagline值
const tagline = ref();
// 更新时间函数
onMounted(() => {
const timer = setInterval(update(), 1000);
onUnmounted(clearInterval.bind(null, timer));
});
function update() {
const duration = dayjs.duration(dayjs().diff(dayjs(firstCommit)));
const diffDays = dayjs().diff(dayjs(firstCommit), "day");
const diffHours = duration.hours();
const diffMinutes = duration.minutes();
const diffSeconds = duration.seconds();
tagline.value = `过去的${diffDays || ""}天${diffHours || ""}时${
diffMinutes < 10 ? `0${diffMinutes}` : diffMinutes
}分${diffSeconds < 10 ? `0${diffSeconds}` : diffSeconds}秒中,累计更新${
pages.length
}篇文章`;
return update;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Home 完整代码
<template>
<VPHero
name="XaviDocs"
text="个人技术文档"
:tagline="tagline"
:image="image"
:actions="actions"
/>
<VPFeatures :features="features" />
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import duration from "dayjs/plugin/duration";
import { onUnmounted, ref, onMounted } from "vue";
import { useData } from "vitepress";
import VPHero from "vitepress/dist/client/theme-default/components/VPHero.vue";
import VPFeatures, {
type Feature,
} from "vitepress/dist/client/theme-default/components/VPFeatures.vue";
import "dayjs/locale/zh-cn";
import { type Pages } from "../.vitepress/readPages";
dayjs.locale("zh-cn");
dayjs.extend(duration);
dayjs.extend(relativeTime);
const data = useData();
const pages: Pages[] = data.theme.value.pages;
// 第一篇文章的发布时间作为建站时间
const firstCommit: number = pages[pages.length - 1].frontMatter.date[0];
// 传入Hero组件的tagline值
const tagline = ref();
const image = { light: "svg/pic1.svg", dark: "svg/pic2.svg" };
const actions = ref([
{
text: "随便逛逛",
link: randomPage(),
},
]);
onMounted(() => {
const timer = setInterval(update(), 1000);
onUnmounted(clearInterval.bind(null, timer));
});
const features = ref<Feature[]>(
pages.map((item) => {
// 用页面的一级标题作为文章标题(因为sidebar中可能是精简的标题)
let regTitle = item.content.match(/^# (\S+?)\s*$/m)?.[1];
// 标题可能用到了变量,需要替换
const matterTitle = regTitle?.match(/\{\{\$frontmatter\.(\S+)\}\}/)?.[1];
if (matterTitle) {
regTitle = item.frontMatter[matterTitle];
}
return {
title: regTitle || item.title,
details: item.content
// 去除html标签
.replace(/<[^>]+?>/g, "")
// 去除标题
.replace(/^#+ [\S]+?\s/gm, "")
// 去除引用
.replace(/^\> /gm, "")
// 只保留反引号内部内容
.replace(/`(\S+?)`/g, "$1")
// 只保留加粗、倾斜符号中的内容
.replace(/\*{1,3}(\S+?)\*{1,3}/g, "$1")
// 只保留跳转内容
.replace(/\[(\S+?)\]\(\S+?\)/g, "$1")
// 去除提示块
.replace(/^:::[\s\S]+?$/gm, "")
// 去除空白字符
.replace(/\s/g, " "),
link: item.path,
// 显示发布时间
linkText: dayjs(item.frontMatter.date[0]).format("YYYY-MM-DD"),
};
})
);
function randomPage(): string {
const length = pages.length - 1;
return pages[Math.floor(Math.random() * length)].path;
}
function update() {
const duration = dayjs.duration(dayjs().diff(dayjs(firstCommit)));
const diffDays = dayjs().diff(dayjs(firstCommit), "day");
const diffHours = duration.hours();
const diffMinutes = duration.minutes();
const diffSeconds = duration.seconds();
tagline.value = `过去的${diffDays || ""}天${diffHours || ""}时${
diffMinutes < 10 ? `0${diffMinutes}` : diffMinutes
}分${diffSeconds < 10 ? `0${diffSeconds}` : diffSeconds}秒中,累计更新${
pages.length
}篇文章`;
return update;
}
</script>
<style scoped>
:deep(.details) {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
</style>
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113