本文是一个 VuePress-next 模板 two-dish-one-fish 的开发记录,演示 DEMO 可以查看这里,可以在 Github 仓库的 wiki 查看模板的使用说明。
「一鱼两吃」是一道很「聪明」的菜品,利用一条鱼的不同部位做出两味,甚至多味菜。我由此获得启发,基于相同的一堆 Markdown 文件作为数据源,搭建一个静态网站,既可以是部落格也可以作为学习笔记管理系统,达到类似「一鱼两吃」的效果。
由于文章主要是文本,而且网站的交互需求不高,所以选择使用静态网页。现在有很多基于 Markdown 文档生成静态网页的工具,基于我目前掌握的技能和需求选择以 VuePress 作为静态网站生成器。
VuePress 已经内置了默认主题只需要进行简单的参数配置就可以做到开箱即用。我的主要工作是基于默认模板进行拓展,优化在检索、浏览文章和笔记时有更好的体验:
- 虽然部落格文章和学习笔记都是以 Markdown 文档为单位,但部落格是面向读者的,一般文章具较完整的前后文内容;而学习笔记则是面向自己,内容较为零碎。所以不能靠一套 UI 来实现两种需求,因此我以主页为统一入口,做了两套文章分类导航页面。Markdown 文档可以通过标记的方式分成两种类型,一种是作为部落格文章和学习笔记,另一种是只作为学习笔记。
- 添加了部落格文章导航页,有三种方式浏览文章:Grid 网格布局、Masonry 瀑布流式布局、List 列表布局。
- 添加了学习笔记导航页,所有的 Markdown 文件都会作为学习笔记展示,可以通过网格嵌套布局来浏览,这些笔记的层级关系和它们在文件存储系统一致,而且通过 D3.js 对这些笔记文件数据进行可视化构建出树图,方便快速浏览。
- 支持给 Markdown 文档加标签,在导航页中可以基于标签对文章进行筛选。
- 遵循 RWD 响应式网页设计原则,可以在不同尺寸的屏幕浏览网页。
项目结构
注意要点
-
插件或者主题的入口文件会在 Node App 中被加载,因此它们需要使用 CommonJS 格式。
-
客户端文件会在 Client App 中被加载,它们最好使用 ESM 格式。
-
VuePress 会在构建过程中生成一个 SSR 应用,用以对页面进行预渲染。
-
如果一个组件在
setup()
中直接使用浏览器 / DOM API ,它会导致构建过程报错,因为这些 API 在 Node.js 的环境中是无法使用的。在这种情况下,你可以选择一种方式:-
将需要访问浏览器 / DOM API 的代码部分写在
onBeforeMount()
或onMounted()
Hook 中。 -
使用
<ClientOnly>
包裹这个组件。 -
如果在组件中导入的模块会立即执行访问 DOM API 操作,使用
<ClientOnly>
包裹这个组件也可能会在构建过程报错,可以使用动态导入的方式引入模块。export default { setup(props) { onMounted(async () => { // dynamic import masonry const module = await import("masonry-layout"); const Masonry = module.default; // do something }) } } 复制代码
-
-
布局组件
Layout
应该包含 Content 组件来展示 Markdown 内容
tailwindcss 整合
项目的打包工具使用 Vite
-
按照 Tailwind CSS 官方文档安装依赖并生成配置文件
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest npx tailwindcss init -p 复制代码
? 执行完以上命令后,会在项目的根文件夹下生成配置文件
tailwind.config.js
,可以配置其中的属性purge
,以在生产环境下优化 Tailwind CSS 文件大小。但是由于项目中存在通过拼接字符串构成的类名,如果执行优化会出现样式丢失的情况,因此没有配置purge
属性。 -
在文件
.vuepress/config.js
中为打包工具配置 postcss 参数// ... module.exports = { // ... bundlerConfig: { // vite 打包工具的选项 viteOptions: { css: { postcss: { plugins: [ require('tailwindcss'), require('autoprefixer') ] } }, } }, // ... } 复制代码
-
在文件
.vuepress/styles/index.scss
的开头中引入 Tailwind@tailwind base; @tailwind components; @tailwind utilities; 复制代码
⚠️ 由于引入了
@tailwind base
可能会会造成默认主题样式的重置,可以手动添加相应的 CSS 样式修正。
插件开发
借助 VuePress 提供的插件 API 可以为网站新增页面(不依赖 Markdown 文件),还可以为指定的页面提供额外的数据。
? VuePress 插件是一个符合插件 API 的 JS 对象或返回值为 JS 对象的函数,具体参考官方文档开发插件一章。
VuePress 提供的插件 API 有多种 Hooks,它们的执行顺序和时间点都不同,可以参考官方文档的核心流程与 Hooks 一节,代码需要在合适 Hooks 下执行才不会报错。
添加时间
参考官方插件 Git 的代码,通过插件 .vuepress/plugins/addTime.js
为 Markdown 文件添加时间修改的信息。
使用 extendsPageOptions Hook 将 Markdown 文件的创建时间 createdTime
和更新时间 updatedTime
作为 Frontmatter 字段添加到相应文件中。
/**
* refer to @vuepress/plugin-git: https://www.npmjs.com/package/@vuepress/plugin-git
*/
const execa = require('execa')
/**
* Check if the git repo is valid
*/
const checkGitRepo = (cwd) => {
try {
execa.commandSync('git log', { cwd })
return true
} catch {
return false
}
}
const getUpdatedTime = async (filePath, cwd) => {
const { stdout } = await execa(
'git',
['--no-pager', 'log', '-1', '--format=%at', filePath],
{
cwd,
}
)
return Number.parseInt(stdout, 10) * 1000
}
const getCreatedTime = async (filePath, cwd) => {
const { stdout } = await execa(
'git',
['--no-pager', 'log', '--diff-filter=A', '--format=%at', filePath],
{
cwd,
}
)
return Number.parseInt(stdout, 10) * 1000
}
const addTime = {
name: 'vuepress-plugin-addTime',
async extendsPageOptions(options, app) {
if (options.filePath) {
filePath = options.filePath;
const cwd = app.dir.source()
const isGitRepoValid = checkGitRepo(cwd)
let createdTime = null;
let updatedTime = null;
if (isGitRepoValid) {
createdTime = await getCreatedTime(filePath, cwd)
updatedTime = await getUpdatedTime(filePath, cwd)
}
return {
frontmatter: {
createdTime,
updatedTime
},
}
} else {
return {}
}
}
}
module.exports = addTime
复制代码
创建页面
参考官方文档添加额外页面一章,通过插件 .vuepress/plugins/createHomePage.js
为网站添加主页,通过插件 .vuepress/plugins/generateFolderPages.js
和 .vuepress/plugins/generateListPages.js
创建一些导航页。
主要使用 createPage
方法异步创建页面,由于导航页需要基于所有 Markdown 文件的数据,代码所以需要在 onInitialized Hook 下执行,此时页面已经加载完毕。
// 创建首页
const { createPage } = require('@vuepress/core')
const createHomePage = (options, app) => {
return {
name: 'vuepress-plugin-createHomePage',
async onInitialized(app) {
// if homepage doesn't exist
if (app.pages.every((page) => page.path !== '/')) {
// async create a homepage
const homepage = await createPage(app, {
path: '/',
// set frontmatter
frontmatter: {
layout: 'HomeLayout',
cards: options.cards || []
},
})
// push the homepage to app.pages
app.pages.push(homepage)
}
},
}
}
module.exports = createHomePage
复制代码
继承主题
参考官方文档继承一个主题一章。
由于我继承的主题并没有发布到 NPM 上而是作为本地主题,因此在文件 .vuepress/config.js
中配置 theme 参数时通过绝对路径来使用它
// ...
module.exports = {
// ...
theme: path.resolve(__dirname, './theme/index.js'),
// ...
}
复制代码
添加布局组件
有两种方式新增布局,然后就可以直接在 Markdown 文件的顶部 Frontmatter 字段 layout 中使用它们:
-
方法一:如果布局组件放置在主题的
.vuepress/theme/layouts/
目录下,需要在主题的入口文件.vuepress/theme/index.js
中通过配置属性layouts
来显式指定const { path } = require('@vuepress/utils') module.exports = { name: 'vuepress-theme-two-dish-cat-fish', extends: '@vuepress/theme-default', // registe 4 layouts layouts: { HomeLayout: path.resolve(__dirname, 'layouts/HomeLayout.vue'), ClassificationLayout: path.resolve(__dirname, 'layouts/ClassificationLayout.vue'), FolderLayout: path.resolve(__dirname, 'layouts/FolderLayout.vue'), Layout: path.resolve(__dirname, 'layouts/Layout.vue'), }, } 复制代码
-
方法二:如果使用默认主题(不进行继承拓展),可以通过插件 API 的 clientAppEnhanceFiles Hook 来注册自定义的布局组件。
-
创建
.vuepress/clientAppEnhance.js
文件 -
在文件中使用
clientAppEnhanceFile
方法注册组件import { defineClientAppEnhance } from '@vuepress/client' import CustomLayout from './CustomLayout.vue' export default defineClientAppEnhance(({ app }) => { app.component('CustomLayout', CustomLayout) }) 复制代码
-
? 如果布局是基于默认主题的布局组件 Layout
进行二次开发,可以使用该组件提供的的插槽
navbar-before
navbar-after
sidebar-top
sidebar-bottom
page-top
page-bottom
树图导航
基于 Markdown 文件在存储系统的位置,使用 D3.js 构建树形图来可视化文件夹的嵌套层级关系。
-
安装 D3.js 依赖
npm install d3@6.5.0
-
在插件
.vuepress/plugins/generateFolderPages.js
中通过遍历所有 Markdown 文件(生成的页面),使用 extendsPageData Hook 为笔记导航页添加额外的数据。const { createPage } = require('@vuepress/core'); const generateFolderPages = (options, app) => { let postFolders = {} options.postFolders.forEach(folder => { postFolders[folder] = { posts: [], tags: [] } }) return { name: 'vuepress-plugin-generateFolderPages', async onInitialized(app) { // rearrange posts to different folder app.pages.forEach((page) => { let folder = ''; if (page.filePathRelative) { folder = page.filePathRelative.split("/")[0] if (!(folder in postFolders)) return } else { return } const post = { key: page.key, title: page.title, path: page.path, pathRelative: page.htmlFilePathRelative, filePathRelative: page.filePathRelative, tags: page.frontmatter.tags || [], createdTime: page.frontmatter.createdTime || null, updatedTime: page.frontmatter.updatedTime || null, date: page.frontmatter.date || null, collection: page.frontmatter.collection || '', collectionOrder: page.frontmatter.collectionOrder || 0, } postFolders[folder].posts.push(post); postFolders[folder].tags = [...new Set([...postFolders[folder].tags, ...post.tags])] }) //... }, extendsPageData: (page, app) => { // add data to each folder navigation pages if (page.frontmatter.folder) { return { postsData: postFolders[page.frontmatter.folder] } } else { return {} } }, } } module.exports = generateFolderPages 复制代码
-
为笔记导航页添加的额外数据是一个数组,在布局组件
.vuepress/theme/layouts/FolderLayout.vue
中将这个扁平的数据结构转换为一个 JS 对象,使它符合 D3.js 用于计算层次布局的数据结构要求。<script> //... function buildPostsTreeData(rootName, postsList) { let tree = { name: rootName, type: "root", parent: null, children: [], }; const mdReg = /\.md$/; postsList.forEach((post) => { const paths = post.filePathRelative.split("/").slice(1); let folder = tree; let currentContent = tree.children; for (let index = 0; index < paths.length; index++) { const path = paths[index]; // let existingPath = getLocation(currentLevel, "name", path); let existingPath = currentContent.find((item) => { return item.name === path; }); if (existingPath) { folder = existingPath; currentContent = existingPath.children; } else if (mdReg.test(path)) { const newPath = { name: path, type: "post", parent: folder, data: post, }; currentContent.push(newPath); } else { const newPath = { name: path, type: "folder", parent: folder, children: [], }; currentContent.push(newPath); folder = newPath; currentContent = newPath.children; } } }); return tree; } export default { setup(props) { // data const data = reactive({ //... folder: "", posts: [], postsTreeData: null, //... }); //... data.folder = page.value.frontmatter.folder; data.posts = page.value.postsData.posts; data.postsTreeData = buildPostsTreeData(data.folder, data.posts); //... } } </script> 复制代码
-
在组件
.vuepress/components/PostsTree.vue
中构建树形图,使用 D3.js 计算树图节点的定位等数据,再使用 Vue3 将数据绑定到 DOM 上控制 svg 的生成。