vitepress-plugin-catalogue 插件深度解析
一、概述
vitepress-plugin-catalogue
是 Teek 主题生态系统中的一个重要插件,专门用于在 VitePress 项目构建时扫描 Markdown 文档并生成目录信息。该插件能够自动识别标记为目录页的文档,并为其生成结构化的目录数据,便于开发者创建美观实用的目录页面。
本文将围绕其设计思路、技术实现、配置选项以及与其他组件间的协作机制展开全面剖析。
二、基本功能
1. 目录数据自动生成
- 在 VitePress 启动时扫描项目中的 Markdown 文档
- 识别具有
catalogue: true
和path
属性的文档作为目录页 - 根据指定路径扫描目录结构,生成层次化的目录数据
- 支持文件和文件夹的混合目录结构
2. 多种数据结构输出
- 生成三种不同形式的目录数据结构:数组形式、映射形式和反向映射形式
- 提供完整的类型定义,便于 TypeScript 项目使用
- 支持自定义数据处理函数,允许用户按需调整数据结构
3. 灵活的文件名解析
- 支持带序号的文件名解析(如
01.introduction.md
) - 支持自定义分隔符(如
01_introduction.md
) - 可从 Markdown 内容中提取标题作为目录项显示文本
- 支持忽略特定文件或目录
4. 构建时处理机制
- 插件在构建时执行,不影响运行时性能
- 支持热重载,但在文件变更后需要重启项目才能生效
- 通过防重复执行机制确保在构建过程中只执行一次
三、配置项详解
1. 基本配置参数
插件支持多种配置选项,可根据实际需求进行灵活配置:
1.1 ignoreList
忽略的文件/文件夹列表,支持字符串和正则表达式:
ignoreList: ["draft", "temp"]; // 忽略名为 draft 和 temp 的文件/文件夹
ignoreList: [/^draft-/]; // 忽略以 draft- 开头的文件/文件夹
1.2 path
指定扫描的根目录,默认为 VitePress 的 srcDir
配置项:
path: "docs"; // 扫描 docs 目录下的文件
1.3 ignoreIndexMd
是否忽略每个目录下的 index.md
文件,默认为 false
:
ignoreIndexMd: true; // 忽略 index.md 文件
1.4 titleFormMd
是否从 Markdown 文件获取第一个一级标题作为目录项文本,默认为 false
:
titleFormMd: true; // 从 Markdown 内容中提取标题
1.5 indexSeparator
自定义序号后的分隔符,默认支持 .
作为分隔符:
indexSeparator: "_"; // 支持 01_introduction.md 格式的文件名
1.6 catalogueItemResolved
解析完每个目录项后的回调函数,可用于自定义处理目录项数据:
catalogueItemResolved: data => {
// 对目录项数据进行自定义处理
return data.map(item => {
// 添加自定义属性或其他处理
return item;
});
};
2. 使用示例
2.1 基本配置
import { defineConfig } from "vitepress";
import Catalogue from "vitepress-plugin-catalogue";
export default defineConfig({
vite: {
plugins: [
Catalogue({
path: "docs",
ignoreList: ["draft", "temp"],
ignoreIndexMd: true,
titleFormMd: true,
}),
],
},
});
2.2 目录页文档配置
在需要作为目录页的 Markdown 文档中添加以下 frontmatter:
---
catalogue: true
path: guide # 指定要扫描生成目录的路径
---
2.3 排除特定文档
在不想纳入目录的 Markdown 文档中添加以下 frontmatter:
---
inCatalogue: false
---
3. 数据访问方式
在 Vue 组件中通过 useData
获取生成的目录数据:
import { useData } from "vitepress";
const { theme } = useData();
// 获取目录数据
const catalogues = theme.value.catalogues;
四、核心函数详解
1. 插件主函数 VitePluginVitePressCatalogue
1.1 功能描述
这是插件的主入口函数,返回一个符合 Vite 插件规范的对象。该函数接收配置选项作为参数,并在 Vite 的 config
钩子中执行目录数据生成逻辑。
export default function VitePluginVitePressCatalogue(option: CatalogueOption = {}): Plugin & { name: string };
1.2 核心实现逻辑
let isExecute = false;
return {
name: "vite-plugin-vitepress-catalogue",
config(config: any) {
// 防止 vitepress build 时重复执行
if (isExecute) return;
isExecute = true;
// 获取 VitePress 配置
const {
site: { themeConfig },
srcDir,
rewrites,
cleanUrls,
} = config.vitepress;
// 确定基础目录
const baseDir = option.path ? join(srcDir, option.path) : srcDir;
// 生成目录数据
const catalogues = createCatalogues({ ...option, path: baseDir }, { rewrites: rewrites.map, cleanUrls });
// 构造最终的目录数据结构
const finalCatalogues: Catalogue = { arr: catalogues, map: {}, inv: {} };
catalogues.forEach(item => {
const { filePath, path, catalogues = [] } = item;
const url = (removeMarkdownExt(rewrites.map[`${filePath}.md`]) || filePath) + (cleanUrls ? "" : ".html");
finalCatalogues.map[filePath] = { url, path, catalogues };
finalCatalogues.inv[path] = { url, filePath, catalogues };
});
// 注入到 themeConfig
themeConfig.catalogues = finalCatalogues;
logger.info("Injected Catalogues Data Successfully. 注入目录页数据成功!");
},
};
2. 目录数据生成函数 createCatalogues
2.1 功能描述
该函数是插件的核心实现,负责扫描指定目录并生成目录数据。
export default (option: CatalogueOption = {}, vitepressConfig: VitePressConfig = {}) => {
const { path = "", ignoreList = [] } = option;
if (!path) return [];
// 获取指定根目录下的所有目录绝对路径
const dirPaths = readDirPaths(path, ignoreList);
// 遍历根目录下的每个子目录
dirPaths.forEach(dirPath => scannerMdFile(dirPath, option, basename(dirPath), vitepressConfig));
return catalogueInfo;
};
2.2 关键实现细节
目录扫描
const readDirPaths = (sourceDir: string, ignoreList: CatalogueOption["ignoreList"] = {}) => {
const ignoreListAll = [...DEFAULT_IGNORE_DIR, ...ignoreList];
const dirPaths: string[] = [];
// 读取目录,返回数组,成员是 root 下所有的目录名(包含文件夹和文件,不递归)
const dirOrFilenames = readdirSync(sourceDir);
dirOrFilenames.forEach(dirOrFilename => {
// 将路径或路径片段的序列解析为绝对路径,等于使用 cd 命令
const secondDirPath = resolve(sourceDir, dirOrFilename);
// 是否为文件夹目录,并排除指定文件夹
if (!isSome(ignoreListAll, dirOrFilename) && statSync(secondDirPath).isDirectory()) {
dirPaths.push(secondDirPath);
}
});
return dirPaths;
};
Markdown 文件扫描
const scannerMdFile = (root: string, option: CatalogueOption, prefix = "", vitepressConfig: VitePressConfig = {}) => {
const { path: srcDir = "", ignoreList = [] } = option;
const ignoreListAll = [...DEFAULT_IGNORE_DIR, ...ignoreList];
// 读取目录名(文件和文件夹)
const dirOrFilenames = readdirSync(root);
dirOrFilenames.forEach(dirOrFilename => {
if (isSome(ignoreListAll, dirOrFilename)) return [];
const filePath = resolve(root, dirOrFilename);
if (statSync(filePath).isDirectory()) {
// 是文件夹目录
scannerMdFile(filePath, option, `${prefix}/${dirOrFilename}`, vitepressConfig);
} else {
// 是文件
if (!isMarkdownFile(dirOrFilename)) return;
const content = readFileSync(filePath, "utf-8");
// 解析出 frontmatter 数据
const { data: { catalogue, path = "" } = {} } = matter(content, {});
if (catalogue && path) {
const filename = basename(dirOrFilename, extname(dirOrFilename));
catalogueInfo.push({
filePath: `${prefix}/${filename}`,
path,
catalogues: createCatalogueList(join(srcDir, path), option, `/${path}/`, vitepressConfig),
});
}
}
});
};
目录列表生成
const createCatalogueList = (
root: string,
option: CatalogueOption,
prefix = "/",
vitepressConfig: VitePressConfig = {}
): CatalogueItem[] => {
if (!existsSync(root)) {
console.warn(`'${root}' 路径不存在,将忽略该目录页的生成`);
return [];
}
const { ignoreIndexMd = false, titleFormMd = false, indexSeparator, catalogueItemResolved } = option;
const catalogueItemList: CatalogueItem[] = [];
// 存放没有序号的目录页
const catalogueItemListNoIndex: CatalogueItem[] = [];
const dirOrFilenames = readdirSync(root);
dirOrFilenames.forEach(dirOrFilename => {
const fileAbsolutePath = resolve(root, dirOrFilename);
const { index: indexStr, title, name } = resolveFileName(dirOrFilename, fileAbsolutePath, indexSeparator);
const index = parseInt(indexStr as string, 10);
if (statSync(fileAbsolutePath).isDirectory()) {
// 是文件夹目录
const mdTitle = titleFormMd ? tryGetMarkdownTitle(root, dirOrFilename) : "";
const catalogueItem = {
title: mdTitle || title,
children: createCatalogueList(fileAbsolutePath, option, `${prefix}${dirOrFilename}/`, vitepressConfig),
frontmatter: {},
};
if (isIllegalIndex(index)) catalogueItemListNoIndex.push(catalogueItem);
else catalogueItemList[index] = catalogueItem;
} else {
// 是文件
if (!isMarkdownFile(dirOrFilename)) return [];
if (ignoreIndexMd && ["index.md", "index.MD"].includes(dirOrFilename)) return [];
const content = readFileSync(fileAbsolutePath, "utf-8");
// 解析出 frontmatter 数据
const { data: frontmatter = {}, content: mdContent } = matter(content, {});
const { title: frontmatterTitle, catalogue, inCatalogue = true } = frontmatter;
// 不扫描目录页和 inCatalogue 为 false 的文档
if (catalogue || !inCatalogue) return [];
// title 获取顺序:md 文件 frontmatter.title > md 文件一级标题 > md 文件名
const mdTitle = titleFormMd ? getTitleFromMarkdown(mdContent) : "";
const finalTitle = frontmatterTitle || mdTitle || title;
const filePath = prefix + name;
const { rewrites = {}, cleanUrls } = vitepressConfig;
const catalogueItem = {
title: finalTitle,
url:
(removeMarkdownExt(rewrites[`${filePath.replace(/^\//, "")}.md`]) || filePath) + (cleanUrls ? "" : ".html"),
frontmatter,
};
if (isIllegalIndex(index)) catalogueItemListNoIndex.push(catalogueItem);
else catalogueItemList[index] = catalogueItem;
}
});
// 将没有序号的 catalogueItemsNoIndex 放到最后面
const data = [...catalogueItemList, ...catalogueItemListNoIndex].filter(Boolean);
return catalogueItemResolved?.(data) ?? data;
};
3. 文件名解析函数
3.1 resolveFileName
该函数负责解析文件名,提取序号、标题等信息:
const resolveFileName = (filename: string, filePath: string, separator: string = ".") => {
const stat = statSync(filePath);
// 处理自定义分隔符
if (separator !== "." && isExtraSeparator(filename, separator)) {
return parseExtraSeparator(filename, stat.isDirectory(), separator);
}
// 处理点(.)分隔符
if (filename.includes(".")) {
return parseDotSeparator(filename, stat.isDirectory());
}
// 无分隔符情况
return { index: "", title: filename, type: "", name: filename };
};
3.2 parseDotSeparator
处理使用点(.)分隔符的文件名:
const parseDotSeparator = (filename: string, isDirectory: boolean) => {
const parts = filename.split(".");
if (parts.length === 2) {
// 简单情况:name.ext 或 index.md
const index = parts[0] === "index" ? "0" : parts[0];
const title = isDirectory ? parts[1] : parts[0];
const type = isDirectory ? "" : parts[1];
const name = parts[0];
return { index, title, type, name };
} else {
// 复杂情况:01.name.ext
const firstDotIndex = filename.indexOf(".");
const lastDotIndex = filename.lastIndexOf(".");
const index = filename.substring(0, firstDotIndex);
// 对于文件,需要处理扩展名,对于目录,则不处理
const title = filename.substring(firstDotIndex + 1, lastDotIndex);
const type = isDirectory ? "" : filename.substring(lastDotIndex + 1);
const name = isDirectory ? filename : filename.substring(0, lastDotIndex);
return { index, title, type, name };
}
};
五、依赖关系分析
1. 生产依赖
1.1 gray-matter
用于解析 Markdown 文件的 frontmatter 内容。该库支持 YAML、JSON、CoffeeScript 等多种 frontmatter 格式。
1.2 picocolors
用于在控制台输出带颜色的日志信息,提升开发体验。
2. 开发依赖
2.1 vite
作为插件运行的基础环境,插件遵循 Vite 插件规范。
3. Node.js 内置模块
插件还使用了多个 Node.js 内置模块:
fs
: 用于文件系统操作,如读取文件内容、检查文件状态等path
: 用于路径处理,如拼接路径、获取文件名等
六、数据结构定义
1. CatalogueOption
配置选项接口
export interface CatalogueOption {
/**
* 忽略的文件/文件夹列表,支持正则表达式
*
* @default []
*/
ignoreList?: Array<RegExp | string>;
/**
* 文章所在的目录,基于 .vitepress 目录层级添加
*
* @default 'vitepress 的 srcDir 配置项'
*/
path?: string;
/**
* 是否忽略每个目录下的 index.md 文件
*
* @default false
*/
ignoreIndexMd?: boolean;
/**
* 控制是否从 md 文件获取第一个一级标题作为侧边栏 text
*
* @default false
* @remark 侧边栏 text 获取顺序
* titleFormMd 为 true:md 文件 frontmatter.title > [md 文件第一个一级标题] > md 文件名
* titleFormMd 为 false:md 文件 frontmatter.title > md 文件名
*/
titleFormMd?: boolean;
/**
* 自定义序号后的分隔符(默认仍然支持 . 作为分隔符,该配置是支持额外分隔符,如自定义分隔符为 _,则文件名 01.a.md 和 01_a.md 都生效)
*/
indexSeparator?: string;
/**
* 解析完每个 catalogueItem 后的回调。每个 catalogueItem 指的是每个目录下的文件数组
*
* @param data 当前 catalogueItem 列表
*/
catalogueItemResolved?: (data: CatalogueItem[]) => CatalogueItem[];
}
2. Catalogue
目录数据接口
export interface Catalogue {
/**
* 目录页数据,map 和 inv 都是由 arr 转换得来的
*/
arr: CatalogueInfo[];
/**
* key 为文件相对路径,value 为 { path:扫描的目录页路径, url: "访问路径", catalogues:目录页数据 }
*/
map: {
[key: string]: { path: string; url: string; catalogues: CatalogueItem[] };
};
/**
* key 为 path:扫描的目录页路径文,value 为 { path:件相对路径, url: "访问路径", catalogues:目录页数据 }
*/
inv: {
[key: string]: { filePath: string; url: string; catalogues: CatalogueItem[] };
};
}
3. CatalogueInfo
目录信息接口
export interface CatalogueInfo {
/**
* 文件相对路径
*/
filePath: string;
/**
* 要扫描的目录路径
*/
path: string;
/**
* 目录页数据
*/
catalogues?: CatalogueItem[];
}
4. CatalogueItem
目录项接口
export interface CatalogueItem {
/**
* 文件名称
*/
title: string;
/**
* 文件 frontmatter
*/
frontmatter: Record<string, any>;
/**
* 文件访问路径
*/
url?: string;
/**
* 子目录
*/
children?: CatalogueItem[];
}
七、工作流程示意图
graph TD
A[VitePress 启动] --> B{Catalogue 插件}
B --> C[读取配置选项]
C --> D[扫描目录结构]
D --> E[查找目录页文档]
E --> F{是否为目录页?}
F -->|是| G[解析 frontmatter]
G --> H[获取扫描路径]
H --> I[扫描指定路径下的文件和目录]
I --> J[生成目录数据]
J --> K[构造多种数据结构]
K --> L[注入到 themeConfig]
F -->|否| M[跳过]
L --> N[插件执行完成]
八、最佳实践与注意事项
1. 文件组织建议
1.1 合理命名文件
为了获得更好的目录排序效果,建议使用带序号的文件名:
docs/
├── 01.introduction.md
├── 02.getting-started.md
├── 03.advanced/
│ ├── 01.configuration.md
│ └── 02.customization.md
└── 04.conclusion.md
1.2 目录页文档配置
在需要作为目录页的文档中正确配置 frontmatter:
---
catalogue: true
path: 03.advanced # 指定要扫描生成目录的路径
---
2. 配置优化建议
2.1 合理使用 ignoreList
Catalogue({
ignoreList: [
"draft", // 忽略草稿文件夹
/\.tmp$/, // 忽略临时文件
"private.md", // 忽略特定文件
],
});
2.2 自定义分隔符
Catalogue({
indexSeparator: "_", // 支持 01_introduction.md 格式
});
3. 数据处理建议
3.1 使用 catalogueItemResolved 自定义数据
Catalogue({
catalogueItemResolved: data => {
// 为每个目录项添加图标属性
return data.map(item => {
return {
...item,
icon: item.frontmatter.icon || "document",
};
});
},
});
4. 注意事项
4.1 热重载限制
插件在构建时执行,文件变更后需要重启项目才能生效:
# 修改文件后需要重启开发服务器
npm run dev
4.2 路径配置
确保 path
配置指向正确的目录,避免路径错误导致无法生成目录数据。
4.3 性能考虑
当目录结构非常复杂时,插件的扫描过程可能会消耗一定时间,建议合理组织目录结构。
九、结语
通过对 vitepress-plugin-catalogue
插件的深入分析,我们可以看到它是一个功能强大且设计精良的 VitePress 插件。它能够自动化生成目录数据,极大地简化了目录页面的开发工作。
该插件的核心优势在于:
- 自动化程度高:只需简单配置即可自动生成目录数据
- 灵活性强:支持多种配置选项,满足不同场景需求
- 兼容性好:支持自定义分隔符和多种文件命名方式
- 易于集成:生成的数据结构清晰,便于在 Vue 组件中使用
在未来的发展中,该插件还可以进一步优化,例如支持更多的自定义选项、提供更丰富的数据处理功能等。但就目前而言,它已经能够很好地满足大部分项目的需求,是构建结构化文档网站的重要工具。