个人主页5.0升级全记录
2023年9月28日
我大概是从2007年起开始写博客的,过去这么多年中,也有更新非常频繁的时间,但总体来看最近几年的确是写得越来越少了。随着公众号、短视频的兴起,大家也不再关注博客,而我也一度不怎么更新,甚至想不起来要写一写文字。
但是最近两年,我又突然发现,虽然写内容的平台到处都是,但只有博客是真正属于自己的,那种永远不会背叛你的安全感是其他平台无法比拟的。
再回头看看自己的博客,发现已经很久没有更新过了,而前一版整理的AMP版本,现在回头看也算是掉坑里了。刚好看到勾股写的博客站迁移至 VitePress 的备忘,于是就打算自己也再整理一下,顺便做一个升级,拍个脑袋就叫“个人主页5.0”。
以下为过程中的一些记录。
换程序
之前使用的是Hexo,是一个典型的“静态博客”,即所有内容都在构建时生成,托管时只有静态的HTML和对应的资源文件。这次打算换成VitePress,也同样是“静态”的,因此核心概念和大致用法上比较类似。按照VitePress的文档,首先安装好依赖,然后将文章(.md
文件)放到docs
目录下即可。
由于之前使用Hexo时就在路由中加了一级/article
,本次迁移过程中也需要建立一个对应的子目录,因此将文章目录由source/_posts
修改为docs/article
即可。
与之对应的,构建脚本也需要做修改,以前用hexo server
和hexo generate
,现在换成vitepress dev docs
和vitepress build docs
。需要注意的是,VitePress是一个“极为先进”的项目,所以需要在package.json
中加入"type": "module"
,否则会报错。
图片的引用路径同样需要修改,按照VitePress的文档,图片最好放在docs
目录下的子目录,例如docs/assets
,然后在文章中使用相对路径引用即可。但最终我还是将图片放到了docs/public/assets
目录下,具体原因见后文。
VitePress的主题是通过docs/.vitepress/theme
目录下的文件来定义的,其中index.js
是主要的入口文件。我们可以自定义一个新的主题来使用,但如果这样做的话默认主题的一些功能就会丢失,例如暗黑模式、顶部导航、404页面等,于是我选择了直接修改默认主题,即从代码仓库中将整个默认主题拷贝过来,然后针对个别地方进行修改。
基本上经过简单的替换以后,文章详情页就可以正常访问了,但是还有一些页面需要处理,例如首页、文章列表页以及一些固定的页面(如关于)。
导航&固定页面
固定页面的实现是最简单的,只需要在docs
目录下新建一个.md
文件即可,例如docs/about.md
,就能通过/about.html
访问。
VitePress中顶部导航是通过docs/.vitepress/config.js
中的themeConfig.nav
配置来实现的:
themeConfig: {
logo: '/logo.png',
nav: [
{ text: '首页', link: '/' },
{ text: '作品', link: '/works.html'},
{ text: '关于', link: '/about.html' },
],
...
}
themeConfig: {
logo: '/logo.png',
nav: [
{ text: '首页', link: '/' },
{ text: '作品', link: '/works.html'},
{ text: '关于', link: '/about.html' },
],
...
}
首页
首页和固定页面的概念是一样的,只需要在docs
目录下新建一个index.md
文件即可,就能通过/
访问。但是如果直接在这个文件中写文字内容的话,它的表现和普通文章是一样的,因此这里需要更多的定制化内容。
到这里终于可以体会到VitePress的高明之处了:所有的.md
文件都会被转换为.vue
组件,然后通过Vue的工具链进行编译,因此我们可以在.md
文件中使用Vue的语法,例如<script>
、<style>
等,基本上和写一个.vue
组件没有太大差异。
也正是因为有这样的机制在,使得我们可以在首页中使用一些Vue生态中可用的开源项目,例如为了编写样式,我引入了TailwindCSS,引入方法和在Vue 3项目中几乎没有区别,值得注意的有几个点:
第一,通常Vue项目中我们会在main.js
或者app.js
中引入一个公共CSS文件,而VitePress中需要在docs/.vitepress/theme/index.js
中引入(例如tailwind.css
),其内容如下:
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind base;
@tailwind components;
@tailwind utilities;
第二,tailwind.config.js
中配置的content
选项需要修改,因为VitePress中的.md
文件也需要处理,因此需要在content
中加入.md
后续。除此之外,因为.vitepress
目录以.
开头,默认会被忽略,需要单独写出来。
为了适配VitePress的暗黑模式,这里可以顺便设置darkMode: class
,这样就可以使用TailwindCSS中的dark:
前缀为暗黑模式匹配样式。
export default {
content: [
'./docs/**/*.{vue,ts,js,md}',
'./docs/.vitepress/**/*.{vue,ts,js,md}',
],
darkMode: 'class',
}
export default {
content: [
'./docs/**/*.{vue,ts,js,md}',
'./docs/.vitepress/**/*.{vue,ts,js,md}',
],
darkMode: 'class',
}
具体到页面内容,就比较无聊了,使用Grid布局放了几个大块块,然后适当做了一点响应式布局的调整就完成了。
文章分类页
有了固定页的经验,按道理分类页也很简单,只需要在docs
目录下新建对应分类的.md
文件即可,例如docs/article/web.md
,就能通过/article/web.html
访问。但这样做有两个问题:
- 分类是写死的,一旦有新增或者变化就需要手工调整
- 无法处理分页的情况
此时就可以使用VitePress的“动态路由”功能,具体而言,是这么做:
- 建立一个带有占位符的
.md
文件,例如docs/article/[category]-[page].md
,其中[category]
和[page]
都是占位符 - 建立一个同名的
.paths.js
文件,例如docs/article/[category]-[page].paths.js
,它的作用是输出[category]
和[page]
占位符的所有可能值,例如web-1
web-2
tech-1
tech-2
等等
这样,就可以通过/article/web-1.html
article/web-2.html
等访问到对应的分类页了。为了额外提供一个“所有”分类,在处理[category]
的值时加入一个all
作为分类,这样就可以通过/article/all-1.html
访问到所有文章的第一页了。
具体的写法大致如下:
// [category]-[page].paths.js
// Recursively get all files in a directory
function getFiles(dir: string): MdFile[] {
const files = readdirSync(dir);
let fileList: MdFile[] = [];
files.forEach((file) => {
const filePath = join(dir, file);
const stats = statSync(filePath);
if (stats.isDirectory()) {
fileList = fileList.concat(getFiles(filePath));
} else if (filePath.endsWith('.md') && !filePath.endsWith('/index.md') && !/\.\/docs\/[^/]+\.md/.test(filePath)) {
const fileContent = readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
const category = data.category || (Array.isArray(data.categories) && data.categories[0]);
fileList.push({ filePath, category });
}
});
return fileList;
}
// Get all markdown files
const data = getFiles('./docs');
// Group files by category
const categories: { [key: string]: MdFile[] } = {};
data.forEach((file) => {
const category = file.category || 'all';
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(file);
if (category !== 'all') {
if (!categories.all) {
categories.all = [];
}
categories.all.push(file);
}
});
// Calculate page count for each category
const pageParams: PageParams[] = [];
Object.entries(categories).forEach(([category, files]) => {
const pageCount = Math.ceil(files.length / PAGE_SIZE);
for (let i = 1; i <= pageCount; i++) {
pageParams.push({ params: { category, page: i } });
}
});
export default {
paths() {
return pageParams;
},
};
// [category]-[page].paths.js
// Recursively get all files in a directory
function getFiles(dir: string): MdFile[] {
const files = readdirSync(dir);
let fileList: MdFile[] = [];
files.forEach((file) => {
const filePath = join(dir, file);
const stats = statSync(filePath);
if (stats.isDirectory()) {
fileList = fileList.concat(getFiles(filePath));
} else if (filePath.endsWith('.md') && !filePath.endsWith('/index.md') && !/\.\/docs\/[^/]+\.md/.test(filePath)) {
const fileContent = readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
const category = data.category || (Array.isArray(data.categories) && data.categories[0]);
fileList.push({ filePath, category });
}
});
return fileList;
}
// Get all markdown files
const data = getFiles('./docs');
// Group files by category
const categories: { [key: string]: MdFile[] } = {};
data.forEach((file) => {
const category = file.category || 'all';
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(file);
if (category !== 'all') {
if (!categories.all) {
categories.all = [];
}
categories.all.push(file);
}
});
// Calculate page count for each category
const pageParams: PageParams[] = [];
Object.entries(categories).forEach(([category, files]) => {
const pageCount = Math.ceil(files.length / PAGE_SIZE);
for (let i = 1; i <= pageCount; i++) {
pageParams.push({ params: { category, page: i } });
}
});
export default {
paths() {
return pageParams;
},
};
写本文的时候在想为什么这里没有使用createContentLoader来写,暂时没有想清楚,可能尝试过碰到了问题,记不太清了,回头再试一下。
按照官方文档,在[category]-[page].md
中,可以使用createContentLoader
来获取文章数据,这一段逻辑被封装到一个单独的文件posts.data.js
中:
export default createContentLoader('article/**/*.md', {
excerpt: '<!-- more -->',
includeSrc: true,
transform(raw): Post[] {
return raw
.filter(({ url }) => url && !/\[.+\]/.test(url))
.map(({ src, url, frontmatter, excerpt }) => ({
title: frontmatter.title,
url,
date: formatDate(frontmatter.date),
category: frontmatter.category || frontmatter.categories?.[0] || 'all',
excerpt,
cover: getCover(src),
}))
.sort((a, b) => b.date.time - a.date.time)
}
});
export default createContentLoader('article/**/*.md', {
excerpt: '<!-- more -->',
includeSrc: true,
transform(raw): Post[] {
return raw
.filter(({ url }) => url && !/\[.+\]/.test(url))
.map(({ src, url, frontmatter, excerpt }) => ({
title: frontmatter.title,
url,
date: formatDate(frontmatter.date),
category: frontmatter.category || frontmatter.categories?.[0] || 'all',
excerpt,
cover: getCover(src),
}))
.sort((a, b) => b.date.time - a.date.time)
}
});
其中getCover()
是一个从原文中解析封面图片的方法。
这里有一个困扰了很久的点,就是图片路径的问题。按照官方文档,推荐将图片放在docs/assets
中,在.md
文件中通过相对路径进行引用,如果这样做的话,这个getCover()
获取到的将是构建前的图片原始地址,而构建之后,图片地址会发生变化,使得封面无法显示。因此最终还是将图片放在了docs/public/assets
中。
渲染的时候根据根据params
中的category
和page
来筛选文章,最后将结果渲染出来即可:
import { data as posts } from '../.vitepress/theme/posts.data'
const { params } = useData()
const PAGE_SIZE = 12
const PAGE = +params.value.page
const CATEGORY = params.value.category
const categoryPosts = computed(() => {
return posts.filter(post => CATEGORY === 'all' || post.category === CATEGORY);
});
const currentPosts = computed(() => {
const start = (PAGE - 1) * PAGE_SIZE
const end = start + PAGE_SIZE
return categoryPosts.value.slice(start, end)
});
import { data as posts } from '../.vitepress/theme/posts.data'
const { params } = useData()
const PAGE_SIZE = 12
const PAGE = +params.value.page
const CATEGORY = params.value.category
const categoryPosts = computed(() => {
return posts.filter(post => CATEGORY === 'all' || post.category === CATEGORY);
});
const currentPosts = computed(() => {
const start = (PAGE - 1) * PAGE_SIZE
const end = start + PAGE_SIZE
return categoryPosts.value.slice(start, end)
});
在界面上还要放一些分类选择、页面选择的组件,比较常规,不过多记录。
影集页面
影集模块中的详情页主要用来展示照片,这里不想使用文章中把图片顺序铺排下来的方式,而是希望有一个自定义的布局,于是扩展了一个单独的GalaryLayout
来展示。
首先编写好布局组件,就是非常常规的Vue组件,使用useData()
方法获取页面对应的数据,然后进行展示即可。为了方便,在docs/.vitepress/config.js
中的transformPageData()
hook中加了一个解析页面图片的功能:
transformPageData(pageData, ctx) {
if (pageData.frontmatter.category === 'galary') {
// @ts-ignore
pageData.images = getImages(readFileSync(join(process.cwd(), 'docs', pageData.relativePath), 'utf8'));
}
return pageData;
},
transformPageData(pageData, ctx) {
if (pageData.frontmatter.category === 'galary') {
// @ts-ignore
pageData.images = getImages(readFileSync(join(process.cwd(), 'docs', pageData.relativePath), 'utf8'));
}
return pageData;
},
这个页面还有待进一步优化,图片的查看方式、整体视觉风格都还不够好。
敏感信息擦除
因为有一些文章中有部分文字不适宜对外发布,但作为个人的文字记录,又不想删掉,于是之前就做了一个敏感信息的标记([*敏感文字*]
),在渲染的时候会擦除标记中的文字。这次迁移的时候发现VitePress不容易做这样的处理,因为它没有给出在渲染前进行原文修改的hook,只能修改渲染后的HTML代码。
于是修改后的代码如下:
export function eraseSensitiveText(text?: string): string {
if (!text) return '';
return text.replace(/\[<em>.*?<\/em>\]/g, '[***]');
}
export function eraseSensitiveText(text?: string): string {
if (!text) return '';
return text.replace(/\[<em>.*?<\/em>\]/g, '[***]');
}
在docs/.vitepress/config.js
中使用trnasformHtml()
hook来调用:
transformHtml(code, id, ctx) {
return eraseSensitiveText(code);
},
transformHtml(code, id, ctx) {
return eraseSensitiveText(code);
},
写成
transformHtml: eraseSensitiveText
的形式也可以,但个人习惯保留回调函数的完整参数信息。
评论
写作的时候Copilot提示说“评论是博客不可缺少的功能”,想了下也有几分道理,我的确是这么想的。
之前使用的评论服务是Disqus,考虑到它已经被墙,且开始提供越来越多与评论无关的功能,这次打算顺手迁移一下。
因为站点本身是静态的,不具备数据库和后端逻辑,因此评论数据需要托管到一个另外的地方。这里大致有几种路线:
- 基于Github issue的评论系统
- 基于LeanCloud之类的BaaS的评论系统
- 基于自托管的评论系统
在方案选择上纠结了很久。首先个人觉得使用issue作为评论数据托管的地方,多多少少有一些滥用的嫌疑,而且Github的API现在也有频率限制,因此放弃这个路线。然后是LeanCloud之类的数据托管,有一些很私人的考虑,不太想再使用国内的服务,而国外的类似产品又不多(也可能是没有去穷尽)。
最后看到了Artalk这个产品,属于自托管的评论系统。看了一下前端界面还是挺符合需求的,但它的后端是用Go写的。我希望这个服务能部署到Cloudflare workers中去,因为刚好它还有D1(SQLite)服务,这样就可以完美解决后端程序和数据库的问题,既不用花钱,也不用自己运维。
于是就翻了一下Artalk的文档和源码,自己写了一个接口完全一致的后端,部署到了Cloudflare workers中。当然,因为我只有自己使用,有一部分特性用不着,所以只实现了最核心的评论相关的特性,其它部分都未做实现。
Cloudfalre有一篇官方教程,讲的就是如何为静态站点写一个评论功能,作为入门参考非常合适。https://developers.cloudflare.com/d1/tutorials/build-a-comments-api/
前端部分的使用和文档基本一致,需要注意的是VitePress生成的是一个SPA应用,即点击站内链接时页面不会刷新,这将导致评论内容也不更新。因此需要手工调用update()
方法传入新的配置并调用reload()
重新拉取评论。
const router = useRouter()
router.onAfterRouteChanged = async () => {
initArtalk();
}
let Artalk, artalk;
const initArtalk = () => {
const conf = {
el: '#artalk-container',
pageKey: location.href.replace(/\?.*/, '').replace(/#.*/, '').replace(/http:\/\/localhost:\d+/, 'https://www.toobug.net'),
pageTitle: document.title,
server: location.host.includes('localhost') ? 'http://localhost:8787' : 'https://comments.toobug.net',
site: 'TooBug',
nestMax: 2,
emoticons: false,
}
if (!artalk) {
artalk = Artalk.init(conf);
} else {
artalk.update(conf);
artalk.reload();
}
};
const router = useRouter()
router.onAfterRouteChanged = async () => {
initArtalk();
}
let Artalk, artalk;
const initArtalk = () => {
const conf = {
el: '#artalk-container',
pageKey: location.href.replace(/\?.*/, '').replace(/#.*/, '').replace(/http:\/\/localhost:\d+/, 'https://www.toobug.net'),
pageTitle: document.title,
server: location.host.includes('localhost') ? 'http://localhost:8787' : 'https://comments.toobug.net',
site: 'TooBug',
nestMax: 2,
emoticons: false,
}
if (!artalk) {
artalk = Artalk.init(conf);
} else {
artalk.update(conf);
artalk.reload();
}
};
在这个过程中还发现了一个Bug,Artalk在调用update()
方法时会重复为按钮绑定事件,从而导致点击时出现重复评论的情况,目前给官方报了issue(https://github.com/ArtalkJS/Artalk/issues/592),期待解决。而我自己先加了一个初始化的标识放在dataset
上,使用pnpm patch
打了一个补丁作为临时解决方案:
function submitBtn(editor) {
editor.refreshSendBtnText();
const $submitBtn = editor.getUI().$submitBtn;
const isInit = $submitBtn.dataset.init === "1";
if (!isInit) {
$submitBtn.addEventListener("click", () => editor.submit());
$submitBtn.dataset.init = "1";
}
}
function submitBtn(editor) {
editor.refreshSendBtnText();
const $submitBtn = editor.getUI().$submitBtn;
const isInit = $submitBtn.dataset.init === "1";
if (!isInit) {
$submitBtn.addEventListener("click", () => editor.submit());
$submitBtn.dataset.init = "1";
}
}
在评论的通知部分,Artalk原版后端应该是邮件通知的,这部分我没去做实现,只是简单接了一个Telegram机器人,这样当别人添加新的评论的时候我就能收到机器人的推送通知。
其他
RSS
RSS基本上照抄Vue博客的代码,源码位于https://github.com/vuejs/blog/blob/main/.vitepress/genFeed.ts。唯一的改动是我没有输入全部文章,因此加了一个.slice()
方法来限制输出的文章数量。
GA & favicon
GA和favicon之类的都很简单,只需要在<head>
中添加几行代码即可,对应到VitePress是在docs/.vitepress/config.js
中添加配置:
head: [
['link', { rel: 'icon', href: '/logo.png' }],
['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX' }],
['script', {}, `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXXXXXX');`],
],
head: [
['link', { rel: 'icon', href: '/logo.png' }],
['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX' }],
['script', {}, `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXXXXXX');`],
],
首页彩蛋
在正式上线之前,临时为首页的模块加了一点点效果:鼠标hover到某个模块上时,会产生一个渐变的背景,并且每一次hover看到的颜色都是随机的。利益于Tailwind的封装,整体代码比较简洁:
<section class="hover:bg-gradient-to-br" :class="getGradient()" @mouseenter="refreshGradient">
...
</section>
<script setup>
const gradientList = {
light: [
['from-amber-200', 'to-yellow-400'],
['from-amber-500', 'to-pink-500'],
['from-violet-200', 'to-pink-200'],
['from-blue-200', 'to-cyan-200'],
['from-teal-200', 'to-teal-500'],
],
dark: [
['from-purple-500', 'to-purple-900'],
['from-blue-800', 'to-indigo-900'],
['from-emerald-500', 'to-emerald-900'],
['from-slate-500', 'to-slate-800'],
['from-violet-600', 'to-indigo-800'],
]
};
const isDark = ref(false);
const checkDark = () => {
isDark.value = document.documentElement.classList.contains('dark');
}
const refreshGradient = () => {
isDark.value = true;
isDark.value = false;
checkDark();
}
const themeList = computed(() => {
const listName = isDark.value ? 'dark' : 'light';
return gradientList[listName];
});
const getGradient = () => {
const index = Math.floor(Math.random() * themeList.value.length);
return themeList.value[index].join(' ')
};
onMounted(checkDark)
</script>
<section class="hover:bg-gradient-to-br" :class="getGradient()" @mouseenter="refreshGradient">
...
</section>
<script setup>
const gradientList = {
light: [
['from-amber-200', 'to-yellow-400'],
['from-amber-500', 'to-pink-500'],
['from-violet-200', 'to-pink-200'],
['from-blue-200', 'to-cyan-200'],
['from-teal-200', 'to-teal-500'],
],
dark: [
['from-purple-500', 'to-purple-900'],
['from-blue-800', 'to-indigo-900'],
['from-emerald-500', 'to-emerald-900'],
['from-slate-500', 'to-slate-800'],
['from-violet-600', 'to-indigo-800'],
]
};
const isDark = ref(false);
const checkDark = () => {
isDark.value = document.documentElement.classList.contains('dark');
}
const refreshGradient = () => {
isDark.value = true;
isDark.value = false;
checkDark();
}
const themeList = computed(() => {
const listName = isDark.value ? 'dark' : 'light';
return gradientList[listName];
});
const getGradient = () => {
const index = Math.floor(Math.random() * themeList.value.length);
return themeList.value[index].join(' ')
};
onMounted(checkDark)
</script>
整体思路是当鼠标hover的时候重置isDark
的值,然后就会依次更新themeList
和getGradient()
的值,从而实现更换渐变背景的效果。
小结
翻了一下提交记录,第一次安装vitepress
依赖的时候是8月28日,不知不觉居然折腾了一个月了,还是花费了不少时间的。从最终结果上来看,基本上达到了我的预期,希望后续可以在这个站点上做更多的记录。
这里也遗留了很多后续需要去处理的问题:
- 整体视觉风格的优化,包括TailwindCSS和VitePress默认主题的颜色不一致的问题处理以及暗黑模式下一些细节的优化
- 影集模块的功能和视觉优化
- 首页动画的微调和优化
- 评论系统的完善,包括邮件通知、回复等功能
- 移动端一些样式优化和交互优化
- 构建性能的优化(目前需要3分钟)
希望以后有时间可以慢慢去完善。
题外话
通过这次迁移,对VitePress的认知也更深刻了。总体上来看,它的设计非常优雅,能够使用的特性并不太多,但是有足够的灵活性来满足各种各样的需求。而且因为它的最终产物是SPA,使用体验极好,相对Astro之类的框架来说,最终的使用体验是更好的。因此如果VitePress在后续的扩展和迭代中保持目前设计上的优异表现,我相信完全可以产生一个更大的生态圈。