主题
Vitepress 统计文章,新建归档页和标签页
Vitepress 虽然没有直接提供统计文章的方法和页面,但是有提供相关 API 帮助我们完成这项工作。详情可以查看 构建时数据加载。
明确文章字段
首先我们要明确我们需要哪些文章字段,这取决于我们的文章卡片要显示哪些内容。我们能想到的必不可少的,有 文章标题,创建时间,标签,url链接。以下是一个简单的示例:
ts
export interface Post {
title: string // 标题
url: string // url
date: [number, number] // 日期:创建日期,更新日期
dateText: [string, string] // 日期文本
abstract: string // 摘要
category?: string | undefined // 分类
tags?: string[] | undefined // 标签
}
获取文章时间
基于以上字段,标题 、url等可以在 Vitepress 内部提供,摘要、标签等,我们在写博客时可以在 frontmatter
中直接写入,剩下只有一个时间字段,我们无法获取。这就需要我们在服务端根据文章创建时间或者git提交时间来获取。
我们创建一个 fileTime.ts
文件来获取文章时间,这里利用 node 的方法和 git 的命令
ts
import { spawn, spawnSync } from "child_process"
import { statSync } from "fs"
/**
* 获取源文件时间
* @param filePath
* @returns 时间对象 { 创建时间, 修改时间 }
*/
export const getFileMetaTime = (filePath) => {
const { birthtimeMs, mtimeMs } = statSync(filePath)
return { birthtimeMs, mtimeMs }
}
/**
* 读取git时间,同步
* @param command git命令
* @param cwd 文件目录
* @returns git时间字符串
*/
export const getGitTimestampSync = (command, cwd) => {
const result = spawnSync('git', command, { cwd })
return result.stdout.toString().trim()
}
/**
* 获取文章时间,异步
* 没有git提交时间的话获取源文件创建和修改时间
* @param filePath
* @returns Promise对象 resolve返回数组 [创建时间,最后一次修改时间]
*/
export const getGitTimestamp = (filePath: string, createdDate, updatedDate) => {
return new Promise<[number, number]>((resolve) => {
let output: number[] = []
// 开启子进程执行git log命令
const child = spawn('git', ['--no-pager', 'log', '--follow', '--pretty="%ci"', filePath])
// 监听输出流
child.stdout.on('data', (d) => {
const data = String(d)
.split('\n')
.map((item) => +new Date(item))
.filter((item) => item)
output.push(...data)
})
// 输出接受后返回
child.on('close', () => {
if (output.length) {
// 返回[发布时间,最近更新时间]
resolve([createdDate || +new Date(output[output.length - 1]), updatedDate || +new Date(output[0])])
} else {
// 没有git提交记录时使用源文件时间
const { birthtimeMs, mtimeMs } = getFileMetaTime(filePath)
resolve([createdDate || birthtimeMs, updatedDate || mtimeMs])
}
})
// 进程错误
child.on('error', () => {
// 获取失败时使用源文件时间
const { birthtimeMs, mtimeMs } = getFileMetaTime(filePath)
resolve([createdDate || birthtimeMs, updatedDate || mtimeMs])
})
})
}
统计文章数据
根据官方api的要求,我们新建 post.data.ts
文件
ts
import { createContentLoader } from 'vitepress'
import { sep, normalize } from 'path'
import { formatDate } from './tools'
import { getGitTimestamp } from './fileTime'
import { Post } from '../theme/type/WPost'
// 导出默认数据
declare const data: Post[]
export { data }
// 获取并处理所有文档数据,供首页等使用
// https://vitepress.dev/zh/guide/data-loading#createcontentloader
export default createContentLoader('post/**/!(*-demo).md', {
// 不包含原始 markdown 源
includeSrc: false,
// 不包含摘录
excerpt: false,
async transform(data) {
const promises: Promise<any>[] = []
const _post: Post[] = []
data.forEach(({ frontmatter, src, url }) => {
const title = frontmatter.title,
_tags = frontmatter?.tags,
category = frontmatter?.category,
abstract = frontmatter?.description,
// 获取手动设置的更新时间
createdDate = frontmatter?.firstCommit ? +new Date(frontmatter.firstCommit) : '',
updatedDate = frontmatter?.lastUpdated ? +new Date(frontmatter.lastUpdated) : '',
// 日期格式
dateOption = formatDate(),
// 链接去掉项目名
link = normalize(url).split(sep).filter((item) => item).join(sep);
// 没有时间的文章根据git时间戳获取
if (createdDate && updatedDate) {
_post.push({
title,
url: link.replace(/post\//, ''),
date: [createdDate, updatedDate],
dateText: [ dateOption.format(createdDate), dateOption.format(updatedDate)],
abstract: abstract,
category: category,
tags: _tags
})
} else {
// https://vitepress.dev/zh/guide/getting-started#file-structure
// 如果你的文档在docs目录下,路径开头需要拼接 docs/ ,末尾需要拼接 .md
const task = getGitTimestamp('docs/' + link.replace(/.html/, '') + '.md', createdDate, updatedDate).then((date) => ({
title,
url: link.replace(/post\//, ''), // 由于使用了rewrites重定向,这里也对url作处理
date: [date[0], date[1]],
dateText: [dateOption.format(date[0]), dateOption.format(date[1])],
abstract: abstract,
category: category,
tags: _tags
}))
promises.push(task)
}
})
let formattedPosts = _post.concat(await Promise.all(promises))
// 发布时间降序排列
formattedPosts = formattedPosts.sort((a, b) => b.date[0] - a.date[0])
return formattedPosts
}
})
这里需要注意的点:
post/**/!(*-demo).md'
中的!(-demo).md
是排除以-demo
结尾的文件,如果你没有的话,可以直接写post/**/*.md
Post
类型来自于我们之前的文章类型定义formatDate
是一个格式化时间的方法tsexport const formatDate = (hasTime?: boolean) => { let formatOption = { year: 'numeric', month: '2-digit', day: '2-digit' } return new Intl.DateTimeFormat('zh', formatOption as Intl.DateTimeFormatOptions) }
获取文章数据主要方法还是利用官方提供的
createContentLoader
最后需要对文章按照发布时间来排序,以便我们显示最新文章
使用文章数据
直接在 .md
页面和 .vue
组件中使用从post.data.ts
导出的 data
数据
vue
<script setup>
import { data } from './post.data.ts'
</script>
<pre>{{ data }}</pre>
归档页和标签页数据处理
归档文章我们一般按年份展示,标签则需要根据选中标签展示对于文章。这里就需要对文章数据进行处理。
新建一个 post.ts
文件,专门用来处理文章数据
ts
// 按年份显示文章
export const postsYearData = (posts: Post[]) => {
const years: Year = {}
posts.forEach((item) => {
const year = new Date(item.date[0]).getFullYear()
if (!years[year]) {
years[year] = []
}
years[year].push(item)
})
return years
}
// 按标签显示文章
export const postsTagData = (posts: Post[]) => {
let tags: Tag = {}
// 固定文章从最早发布日期开始,以便标签数组能稳定显示(不会因为新发布文章而导致顺序变化)
const fixPosts = [...posts].sort((a, b) => a.date[0] - b.date[0])
let tagNames: string[] = []
fixPosts.forEach((item) => {
item.tags?.forEach((tag) => {
if (tagNames.indexOf(tag) === -1) {
tagNames.push(tag)
tags[tag] = []
}
tags[tag].push(item)
})
})
// 排序
for(const key in tags) {
tags[key].sort((a, b) => b.date[0] - a.date[0])
}
return tags
}
新建归档页
新建页面可以查看另一篇文章 Vitepress 新建页面和注册组件
这里以归档页面简单做一个示例:
新建一个 Post/index.vue
组件,内容如下:
vue
<template>
<div id="main">
<div class="title">
<h1>
全部文章<span> - {{ postLength || '' }} 篇</span>
</h1>
</div>
<div id="post">
<weiz-post-list :postList="postList" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { data } from '../../../utils/post.data'
import { postsYearData, Year } from '../../../utils/post'
import { Post } from '../../type/WPost'
export interface PostList {
title: string
posts: Post[]
}
let postLength = ref(0)
let posts = ref<Year>({})
let postList = ref<PostList[]>([{
title: new Date().getFullYear().toString(),
posts: []
}])
const getPost = () => {
let _list: PostList[] = []
for (const key in posts.value) {
_list.push({
title: key,
posts: posts.value[key]
})
}
postList.value = _list.reverse()
}
const getPostLength = () => {
let length = 0
postList.value.forEach((item) => {
length += item.posts.length
})
postLength.value = length
}
onMounted(() => {
posts.value = postsYearData(data)
getPost()
getPostLength()
})
</script>
其中的 weiz-post-list
文章列表组件,你可以自由发挥
然后将此组件注册到全局主题中
ts
import Post from './Post/index.vue'
export default {
enhanceApp({ app }) {
// 注册全局组件
app.component('post', Post)
}
}
最后我们新建一个 posts.md
页面,应用此组件即可
md
---
layout: post
title: 归档页
description: 这是唯知笔记网站的文章归档界面……
---
标签页类似,只是多一个选择标签过滤数据的事件,这里不再赘述
另一种思路:生成本地 json
根据官方文档描述,还有一种思路就是在项目构建时,生成 json 文件,前端请求 json 文件获取数据。因为也尝试过这种方式,这里列出来仅供大家参考
新建文件 loadPosts.ts
,注意这里的文件目录是在 config 下
ts
// 代码和 docs/.vitepress/utils/post.data.ts 类似,细节不太一样
import { createContentLoader } from 'vitepress'
import fs from 'fs'
import path from 'path'
import { sep, normalize } from 'path'
import { formatDate } from '../utils/tools'
import { getGitTimestamp } from '../utils/fileTime'
import { Post } from '../utils/post'
export const loadPosts = async (mode) => {
// 使用 createContentLoader 加载 Markdown 文件
const posts = await createContentLoader('post/**/!(*-demo).md', {
// 包含原始 markdown 源
includeSrc: false,
// 包含摘录
excerpt: false,
}).load()
//
const promises: Promise<any>[] = []
const _post: Post[] = []
posts.forEach(({ frontmatter, src, url }) => {
const title = frontmatter.title,
_tags = frontmatter?.tags,
category = frontmatter?.category,
abstract = frontmatter?.description,
// 获取手动设置的更新时间
createdDate = frontmatter?.firstCommit ? +new Date(frontmatter.firstCommit) : '',
updatedDate = frontmatter?.lastUpdated ? +new Date(frontmatter.lastUpdated) : '',
// 日期格式
dateOption = formatDate(),
// 链接去掉项目名
link = normalize(url).split(sep).filter((item) => item).join(sep);
// 没有时间的文章根据git时间戳获取
if (createdDate && updatedDate) {
_post.push({
title,
url: link.replace(/post\//, ''),
date: [createdDate, updatedDate],
dateText: [ dateOption.format(createdDate), dateOption.format(updatedDate)],
abstract: abstract,
category: category,
tags: _tags
})
} else {
// https://vitepress.dev/zh/guide/getting-started#file-structure
// 如果你的文档在docs目录下,路径开头需要拼接 docs/ ,末尾需要拼接 .md
const task = getGitTimestamp('docs/' + link.replace(/.html/, '') + '.md').then((date) => ({
title,
url: link.replace(/post\//, ''), // 由于使用了rewrites重定向,这里也对url作处理
date: [date[0], date[1]],
dateText: [dateOption.format(date[0]), dateOption.format(date[1])],
abstract: abstract,
category: category,
tags: _tags
}))
promises.push(task)
}
})
let formattedPosts = _post.concat(await Promise.all(promises))
// 发布时间降序排列
formattedPosts = formattedPosts.sort((a, b) => b.date[0] - a.date[0])
// 定义输出路径
const outputPath = path.resolve(mode === 'production' ? './dist/posts.json' : './docs/posts.json')
// 确保目标目录存在,如果不存在则创建,否则首次构建会报错
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true }) // 递归创建目录
}
// 将数据写入 JSON 文件
fs.writeFileSync(outputPath, JSON.stringify(formattedPosts, null, 2))
console.log('Generated posts.json successfully!')
}
然后修改 confi 配置,在开发和生产构建阶段,分别调用此方法
ts
import { loadPosts } from './loadPosts'
export default async ({ mode }) => {
// https://vitepress.dev/zh/reference/site-config
return defineConfig({
// 其他配置省略
vite: {
plugins: [
// 开发环境执行
{
name: 'load-posts-plugin',
async configResolved() {
await loadPosts(mode)
}
}
],
},
// 构建时执行
async buildEnd() {
try {
// 加载文章
loadPosts(mode)
} catch (error) {
console.error('Error during buildEnd:', error)
}
}
})
}
这样本地项目启动或者项目生产构建时,就会生成一个包含文章数据的json文件。
对应的,在客户端我们发送一个请求去获取json文件,从而拿到数据。定义以下方法
ts
export const postsData = async () => {
const response = await fetch(window.location.origin + '/posts.json')
const posts: Post[] = await response.json()
return posts
}
在归档页或者标签页
vue
<script setup lang="ts">
// 省略其他
onMounted(async() => {
posts.value = await postsData()
})
</script>
通过以上方式我们也能拿到文章数据,正常展示在页面上。这样的坏处是,每次都要发送请求,好处是或许我们可以进一步扩展,做一个中间件对数据分页,按需请求