Skip to content

VitePress 侧边栏自动生成

组件简介

这个模块将 VitePress 的 sidebar 配置从手写硬编码改为基于文件系统自动生成。它的核心思路是扫描 posts/ 目录结构,按目录层级自动映射为侧边栏分组,从 markdown 文件的 # 标题自动提取显示文本,让新增文章时无需手动修改配置。

对于个人博客来说,文章数量会随时间持续增长。如果每次新增文章都要在 config.mts 里手动加一行 sidebar 条目,配置文件会越来越臃肿,也容易漏写或写错链接。自动生成之后,只要在对应目录下创建 .md 文件,侧边栏就会自动更新。

核心功能

  • 根据 posts/ 目录结构自动递归生成 sidebar 配置
  • 从 markdown 文件第一个 # 标题自动提取文章显示名
  • 通过映射表将目录名转为中文显示名(如 metaphysics-idle玄学放置
  • 支持嵌套子目录自动归组(如 front-end/deploy部署相关
  • 文件按 git 首次提交时间排序(文章创建顺序),git 不可用时回退到文件系统时间
  • 自动去除标题中与父级分组名重复的前缀(如 玄学放置 · )和后缀(如 组件),避免侧边栏显示冗余
  • 新增文章只需创建 .md 文件,无需修改配置

改造前的问题

原来的 sidebar 配置直接写在 .vitepress/config.mts 中:

ts
sidebar: [
  {
    text: '玄学放置',
    collapsed: true,
    items: [
      { text: '01 · 项目初始化与核心组件', link: '/posts/metaphysics-idle/01-init' },
      { text: '02 · 像素图标体系', link: '/posts/metaphysics-idle/02-pixel-icons' },
      // 每新增一篇都要手动加一行……
    ]
  },
  // …
]

每加一篇新文章就要改配置、拼链接,容易出错且配置文件越来越长。

实现思路

整体结构

新建 .vitepress/sidebar.ts,导出一个 generateSidebar() 函数,在 config.mts 中调用:

ts
// config.mts
import { generateSidebar } from './sidebar'

export default defineConfig({
  themeConfig: {
    sidebar: generateSidebar(),
  }
})

目录名映射

通过 dirLabelMap 维护目录名到中文显示名的映射,新增分类目录时只需加一行:

ts
const dirLabelMap: Record<string, string> = {
  'front-end': '前端',
  'components': '组件',
  'metaphysics-idle': '玄学放置',
  'my-life': '生活',
  'deploy': '部署相关',
  'eight-part': '八股',
}

映射表中没有的目录名会直接作为显示名兜底,不会报错。

标题提取

从 markdown 文件的第一个 # 行读取文章标题,作为 sidebar 条目的 text。如果文件没有 # 标题,则从文件名推断(去掉 .md 扩展名和数字前缀),而不是回退到完整文件路径:

ts
function extractTitle(filePath: string): string {
  try {
    const content = readFileSync(filePath, 'utf-8')
    const match = content.match(/^#\s+(.+)$/m)
    if (match) return match[1].trim()
  } catch {
    // 文件不可读时走下面的兜底
  }
  // 无 # 标题时从文件名推断,去掉扩展名和数字前缀(如 "01-init.md" → "init")
  const base = filePath.replace(/\\/g, '/').split('/').pop()!.replace(/\.md$/, '')
  return base.replace(/^\d+-/, '')
}

不需要在 frontmatter 中额外声明 title,沿用项目已有的"首行 H1 即标题"约定。无标题的文件也能正常显示,但建议还是加上 # 标题以获得更友好的侧边栏文本。

递归扫描

scanDir 递归扫描目录,先处理当前层级的 .md 文件,再处理子目录:

  • 当前目录下有 .md 文件 → 直接作为条目
  • 子目录下有 .md 文件 → 归为一组,用 dirLabelMap 映射显示名
  • 子目录中还有嵌套分组 → 自动生成多级结构

排序

文章按 git 首次提交时间排序,反映真实的创作顺序。通过 git log --diff-filter=A 获取每个文件的最早提交时间,git 不可用时回退到文件系统 birthtime。首次获取后缓存结果,避免重复执行 git 命令:

ts
function getFileCreatedTime(filePath: string): string {
  const cached = fileCreatedTimeCache.get(filePath)
  if (cached !== undefined) return cached

  try {
    const time = execSync(
      `git log --diff-filter=A --follow --format=%aI -1 -- "${filePath}"`,
      { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
    ).toString().trim()
    fileCreatedTimeCache.set(filePath, time)
    return time
  } catch {
    // 回退到文件系统时间
  }
}

目录排序通过 dirOrder 数组指定优先级,未列出的按字母排序兜底:

ts
const dirOrder = ['front-end', 'components', 'metaphysics-idle', 'my-life']

折叠控制

通过 collapsedDirs 集中管理哪些目录默认折叠:

ts
const collapsedDirs = new Set(['front-end', 'components', 'metaphysics-idle'])

内容较多的分组默认收起,用户点击展开;内容少的(如"生活"只有一篇)默认展开。

标题去重

sidebar 条目的标题自动去除与父级分组名重复的前缀和后缀,避免在已分组的情况下重复显示:

ts
function cleanTitle(title: string, parentLabel: string): string {
  // 去除 "分组名 · " 前缀(如 "玄学放置 · xxx" → "xxx")
  const prefix = `${parentLabel} · `
  if (title.startsWith(prefix)) return title.slice(prefix.length)
  // 去除 "分组名" 后缀(如 "xxx组件" → "xxx")
  if (title.endsWith(parentLabel) && title.length - parentLabel.length >= 2) {
    return title.slice(0, -parentLabel.length).trim()
  }
  return title
}

实际效果:

分组原标题去重后
玄学放置玄学放置 · 项目初始化与核心组件封装项目初始化与核心组件封装
组件VitePress 文章元信息组件VitePress 文章元信息
组件VitePress 侧边栏自动生成VitePress 侧边栏自动生成(不变)

注意 cleanTitle 只影响 sidebar 的显示文本,不修改文章正文的 H1 标题。

使用方式

源码位于 .vitepress/sidebar.ts,通过以下入口接入:

  1. config.mts 中引入并调用 generateSidebar()

后续新增文章时,只需在 posts/ 对应目录下创建 .md 文件即可。新增分类目录时,在 dirLabelMapdirOrder 中加一行映射。

扩展方向

  • 如果后续文章数量进一步增长,可以改为"多 sidebar"模式——sidebar 配置为对象,按路径前缀返回不同的侧边栏,让每个分类只显示自己的文章列表
  • 如果需要对单篇文章定制显示名,可以在 frontmatter 中加 sidebarTitle 字段,在 extractTitle 中优先读取