Skip to content

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
  }
})

这里需要注意的点:

  1. post/**/!(*-demo).md' 中的 !(-demo).md 是排除以 -demo结尾的文件,如果你没有的话,可以直接写 post/**/*.md

  2. Post 类型来自于我们之前的文章类型定义

  3. formatDate 是一个格式化时间的方法

    ts
    export const formatDate = (hasTime?: boolean) => {
      let formatOption = {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      }
      return new Intl.DateTimeFormat('zh', formatOption as Intl.DateTimeFormatOptions)
    }
  4. 获取文章数据主要方法还是利用官方提供的 createContentLoader

  5. 最后需要对文章按照发布时间来排序,以便我们显示最新文章

使用文章数据

直接在 .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>

通过以上方式我们也能拿到文章数据,正常展示在页面上。这样的坏处是,每次都要发送请求,好处是或许我们可以进一步扩展,做一个中间件对数据分页,按需请求