likes
comments
collection
share

开发 VitePress 自动生成目录插件

作者站长头像
站长
· 阅读数 25

开发 VitePress 自动生成目录插件

VitePress 是继 VuePress 后全新的静态站点生成器,基于 Vite 实现最大的特点就是快,官网对其的介绍是 Simple, powerful, and fast,非常适合搭建博客和组件库文档等

最近 VitePress 更新到了 v1.0.0-rc 版本,基本处于可以放心使用的状态。如果还不熟悉 VitePress,可以查看我的个人文档中相关介绍文章

使用中的问题

使用 VitePress 搭建文档站点的一个问题是:当文章数量上去后,每次都需要手动配置 sidebar 目录,比较繁琐。我们可以尝试开发一款插件,读取项目中的文章,并根据目录结构自动生成 sidebar 以及 nav 配置

插件已开源并发布到 npm ,可以直接在仓库中查看 README 使用或查看源码(觉得有用的话点个 ⭐ 再走吧)

下文会主要介绍插件开发思路以及相关代码

需求分析

  • 根据文档目录结构生成菜单,第一层目录作为 nav ,子文件和子文件夹作为 nav 下的 sidebar (按子文件夹层级生成,忽略空文件夹)
  • 支持配置监听范围,排除无需处理的目录
  • 支持自定义文件、文件夹配置,例如是否展示、排序规则、展示名称
  • 支持开发热更新

比如目录格式为:

- A
  - a1
    - a1-1.md
    - a1-2.md
  - a2.md
- B
  - b1
    - b1-1.md
  - b2

生成的配置就是:

{
  nav: [
    {
      text: 'A',
      activeMatch: '/A/',
      link: '/A/a1/a1-1',
    },
    {
      text: 'B',
      activeMatch: '/B/',
      link: '/B/b1/b1-1',
    },
  ],
  sidebar: {
    '/A/': [
      {
        text: 'a1',
        items: [
          { text: 'a1-1', link: '/A/a1/a1-1' },
          { text: 'a1-2', link: '/A/a1/a1-2' },
        ],
      },
      { text: 'a2', link: '/A/a2' },
    ],
    '/B/': [
      {
        text: 'b1',
        items: [
          { text: 'b1-1', link: '/B/b1/b1-1' },
        ],
      },
    ],
  }
}

VitePress 中有两个关键的配置选项:

  1. srcDir:定义文档根目录,默认为项目根目录。例如在 VitePress 作为组件库文档时常将 docs 作为文档目录,就可以通过 srcDir 设置
  2. srcExclude:值为 glob 表达式,作用是排除掉匹配的文件,被排除的文件无法访问到(打包时生效)

我们的插件应该基于这两个配置,仅读取 srcDir 内,非 scrExclude 的文件

创建项目

定了需求后开始开发,因为需要发布到 npm,所以创建一个 vite-plugin-vitepress-auto-nav 项目

根据 Vite 官方的插件约定,使用了 Vite 特有钩子的插件应该以 vite-plugin 开头命名,便于其他开发者发现我们的插件

# 创建项目目录
mkdir vite-plugin-vitepress-auto-nav

# 进入项目
cd vite-plugin-vitepress-auto-nav

# 初始化项目
pnpm init

# 使用 ts 开发,生成 tsconfig.json 文件
npx tsc --init

修改 package.json 文件:

{
  "name": "插件发布名称",
  "version": "1.0.0",
  "description": "插件描述",
  "keywords": ["插件关键字"],
  "homepage": "项目主页",
  "bugs": "bug 提交页面",
  "repository": {
    "type": "git",
    "url": "git+仓库地址"
  },
  "license": "开源协议",
  "author": "作者",
  "type": "module" // vite 默认仅支持 esm
  // ...
}

修改 tsconfig.json 文件:

{
  "compilerOptions": {
    "declaration": true, // 生成 .d.ts 文件
    "target": "ESNext", // 无需考虑生成代码兼容性
    "module": "ESNext", // 生成代码使用 es6 模块化
    "moduleResolution": "Node", // 模块化解析策略
    "lib": ["ESNext"], // 包含新 API 类型定义
    "isolatedModules": true // Vite 配置要求(https://cn.vitejs.dev/guide/features.html#typescript-compiler-options)
    // 根据项目需求进行其他配置...
  }
}

之后添加 src 文件夹并创建 index.ts 文件,作为我们的项目入口

LICENSE、README、Lint 配置等此处忽略,可以自行添加

功能开发

且本插件应该使用在基于 Vite 和 VitePress 的项目,且插件中会用到相关的类型定义。所以我们需要将 Vite 和 VitePress 作为对等依赖项( peerDependencies )安装

pnpm i --save-peer vite vitepress`

安装后因为 VitePress 当前处在 rc 版本,package.json 中会直接固定版本号,为了 VitePress 更新后用户使用时不报对等依赖版本不匹配的错误,可以修改版本号限制为 >=1.0.0-rc.1 <2.0.0

TS 类型定义

通过上面的需求分析可以先确定插件所接收的配置

  • 支持配置监听范围:使用 pattern 参数接收 glob 字符串或字符串数组
  • 对文件或文件夹单独的配置:使用 itemsSetting 参数接收对象,键名为文件名或路径,值包括是否展示、排序权重、重命名名称、collapsed
  • 为了更方便的对目录进行排序,可以使用 compareFn 参数接收排序函数,函数参数应该包括名称、是否是文件夹、创建时间、修改时间

为了更方便的处理逻辑,可以将目录信息抽象为树形数据结构,同层目录数据使用数组存储,子目录数据存放在 children 属性中

可以得出 TS 接口类型:

/** 插件配置项 */
interface Options {
  /**
   * glob 匹配表达式
   * 会匹配 srcDir 目录下,除 srcExclude 配置外的,满足表达式的 md 文件
   * 默认:**.md
   */
  pattern?: string | string[];
  /**
   * 对特定文件或文件夹进行配置
   * 键名为文件、文件夹名或路径(会从外层文件夹往里进行查找,md 扩展名可以省略;名称存在重复时,可以用路径区分)
   */
  itemsSetting?: Record<string, ItemOption>;
  /**
   * 自定义排序方法,同级文件、文件夹会调用这个函数进行排序
   * 默认会先按照 sort 权重降序排列,再按照创建时间升序排列
   */
  compareFn?: (a: FileInfo, b: FileInfo) => number;
}

/** 单个文件、文件夹配置项 */
interface ItemOption {
  /** 是否展示 */
  hide?: boolean;
  /** 排序权重,权重越大越靠前 */
  sort?: number;
  /** 重定义展示名称 */
  title?: string;
  /** 同 sidebar 中的配置,默认 false(支持折叠,默认展开) */
  collapsed?: boolean;
}

/** 文件或文件夹数据结构 */
interface FileInfo extends ItemOption {
  /** 文件、文件夹名 */
  name: string;
  /** 是否是文件夹 */
  isFolder: boolean;
  /** 文件首次提交时间或本地文件创建时间 */
  createTime: number;
  /** 文件最新提交时间或本地文件更新时间 */
  updateTime: number;
  children: FileInfo[];
}

插件基础结构

Vite 插件要求默认导出一个返回实际插件对象的工厂函数,插件相关的钩子可以在官方文档等进行了解

VitePress 源码中也是使用 Vite 插件的形式对文档进行处理并在 config 钩子中注入了 VitePress 相关配置(源码:src\node\plugin.ts),而正好我们的插件需求是修改 VitePress 配置。所以可以直接在 config 钩子中进行逻辑处理

export default function AutoNav(options: Options = {}): Plugin {
  return {
    // 插件名
    name: "vite-plugin-vitepress-auto-nav",
    configureServer({ config, watcher }: ViteDevServer) {
      // 热更新处理,后文会介绍
    },
    async config(config) {
      // VitePress 已经注入过配置,这里标记出配置的 ts 类型
      const _config = config as UserConfig & { vitepress: SiteConfig };

      // 1. 获取全部需要的 md 文档路径(这一步自动忽略了空目录)
      // 2. 处理文档路径为结构化的数据
      // 3. 用户未配置 nav 时,使用结构化数据第一层生成 nav 并修改 _config
      // 4. 通过结构化数据生成 sidebar 并修改 _config
      // 5. 返回修改后的 _config ,插件逻辑完成
    },
  };
}

提取结构化目录数据

VitePress contentLoader (源码:src\node\contentLoader.ts)中就使用了 fast-glob 库提取页面路径,我们可以借鉴其中的代码进行文章目录提取

首先安装 fast-glob(需要注意插件开发时只有运行时依赖会在插件被安装时一并安装,所以这里不能加 -D

pnpm i fast-glob

从 config 中获取关键的 srcDir 和 srcExclude 属性,分别作为 glob 匹配的根目录和忽略目录即可得到所有的文章路径列表

// 从vitepress配置中获取文档根路径与要排除的文档
const {
  vitepress: {
    userConfig: { srcExclude = [], srcDir = "./" },
  },
} = _config;

// 默认匹配全部
const pattern = options.pattern || "**.md";

// 读取需要的md文件路径
const paths = (
  await glob(pattern, {
    cwd: srcDir,
    ignore: ["**/node_modules/**", "**/dist/**", "index.md", ...srcExclude],
  })
).map((path) => normalize(path)); // 抹平不同系统的路径格式差异

// 处理文件路径数组为多级结构化数据
const data = await serializationPaths(paths, options, srcDir);

之后通过 serializationPaths 函数,接收插件配置中的 itemsSetting 对象,遍历获取的文章路径列表,对每个路径逐级拆分获取对应的相关信息并处理为树形结构数据存储:

async function serializationPaths(
  paths: string[],
  { itemsSetting = {} }: Options = {},
  srcDir: string
) {
  // 统一路径格式,便于匹配
  for (const key in itemsSetting) {
    itemsSetting[join(srcDir, key)] = itemsSetting[key];
  }
  const pathKeys = Object.keys(itemsSetting);

  const root: FileInfo[] = [];

  // 遍历处理每一条文章路径
  for (const path of paths) {
    // 记录当前处理文件、文件夹的父级
    let currentNode = root;
    // 记录当前处理文件、文件夹的路径
    let currentPath = "";

    // 获取路径中的每一级名称
    const pathParts = join(srcDir, path).split(sep);

    for (const name of pathParts) {
      currentPath = join(currentPath, name);

      // 获取文章时间戳信息
      const [createTime, updateTime] = await getTimestamp(currentPath);

      // 通过是否有扩展名判断是文件还是文件夹
      const isFolder = !name.includes(".");

      // 查找是否有自定义配置
      // 先按路径匹配
      let customInfoKey = pathKeys.find((p) => currentPath === p);
      // 再按文件名匹配
      if (!customInfoKey) {
        customInfoKey = pathKeys.find(
          (p) => name === p || name.replace(".md", "") === p
        );
      }
      const customInfo = customInfoKey ? itemsSetting[customInfoKey] : {};

      // 跳过不展示的部分
      if (customInfo.hide) break;

      // 查找该层级中是否已经处理过这个文件或文件夹
      let childNode = currentNode.find((node) => node.name === name);
      // 若未处理过,整理数据并添加到数组
      if (!childNode) {
        childNode = {
          ...customInfo,
          name,
          isFolder,
          createTime,
          updateTime,
          children: [],
        };
        currentNode.push(childNode);
      }

      currentNode = childNode.children;
    }
  }
  return root;
}

获取时间戳

contentLoader 源码中使用 fs.statSync 直接读取本地文件的创建时间与修改时间。但因为我们的仓库在 Github 中,可能会多台设备编辑,会存在不同设备本地文件时间信息不一致的情况

所以获取时间戳更好的方法是获取 git 的提交记录,在没有提交记录或获取失败时使用 fs.statSync 兜底处理(也有弊端,直接修改文件名后会丢失提交记录)

可以使用 node 自带的 fs 库,也可以安装 fs-extra,安装时同样的不能使用 -D

pnpm i fs-extra

function getTimestamp(filePath: string) {
  return new Promise<[number, number]>((resolve) => {
    let output: number[] = [];

    // 开启子进程执行git log命令
    const child = spawn("git", [
      "--no-pager",
      "log",
      '--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([+new Date(output[output.length - 1]), +new Date(output[0])]);
      } else {
        // 没有提交记录时获取文件时间
        const { birthtimeMs, ctimeMs } = fs.statSync(filePath);
        resolve([birthtimeMs, ctimeMs]);
      }
    });

    // 获取失败时使用文件时间
    child.on("error", () => {
      const { birthtimeMs, ctimeMs } = fs.statSync(filePath);
      resolve([birthtimeMs, ctimeMs]);
    });
  });
}

数据排序

插件提供自定义排序方法、单独的权重配置、默认排序三种方式,优先使用自定义排序方法 compareFn 。未定义排序方法时按照 sort 权重由大到小,创建时间由早到晚的顺序排列

/** 对结构化后的多级数组数据进行逐级排序 */
function sortStructuredData(
  data: FileInfo[],
  compareFn?: (a: FileInfo, b: FileInfo) => number
): FileInfo[] {
  return data.sort(compareFn || defaultCompareFn).map((item) => {
    if (item.children && item.children.length > 0) {
      item.children = sortStructuredData(item.children, compareFn);
    }
    return item;
  });
}

/** 默认排序方法,优先按 sort 权重降序,其次按创建时间升序 */
function defaultCompareFn(a: FileInfo, b: FileInfo) {
  if (a.sort !== undefined && b.sort !== undefined) {
    // 权重相同时按创建时间升序排列
    return b.sort - a.sort || a.createTime - b.createTime;
  } else if (a.sort !== undefined) {
    return -1;
  } else if (b.sort !== undefined) {
    return 1;
  } else {
    return a.createTime - b.createTime;
  }
}

生成 nav

nav 生成直接取结构化数据的第一层构建即可。因为 nav 数据较为简单,如果需要更细致的配置可以直接在 VitePress config 中配置。所以插件采用已有 nav 配置时不做更改,未定义时再生成的策略。不影响插件体验,又能简化插件的参数数量和处理逻辑

// config 钩子中获取 nav 配置
const {
  vitepress: {
    site: {
      themeConfig: { nav },
    },
  },
} = _config;
// 未配置时再生成
if (!nav) {
  _config.vitepress.site.themeConfig.nav = generateNav(data);
}

// 遍历结构化数据,使用第一层构建 nav
function generateNav(structuredData: FileInfo[]) {
  return structuredData.map((item) => ({
    text: item.title || item.name,
    activeMatch: `/${item.name}/`,
    link: getFirstArticleFromFolder(item),
  }));
}

// 获取目录中第一篇文章作为 nav 链接地址
function getFirstArticleFromFolder(data: FileInfo, path = "") {
  path += `/${data.name}`;
  if (data.children.length > 0) {
    return getFirstArticleFromFolder(data.children[0], path);
  } else {
    // 显示名称应除掉扩展名
    return path.replace(".md", "");
  }
}

生成 sidebar

sidebar 可以通过遍历结构化数据第一层(即每一个 nav 项),再递归的生成 sidebar 数据

// config 钩子中生成侧边栏目录
const sidebar = generateSidebar(data);
_config.vitepress.site.themeConfig.sidebar = sidebar;

function generateSidebar(structuredData: FileInfo[]): DefaultTheme.Sidebar {
  const sidebar: DefaultTheme.Sidebar = {};

  // 遍历首层目录(nav),递归生成对应的 sidebar
  for (const { name, children } of structuredData) {
    sidebar[`/${name}/`] = traverseSubFile(children, `/${name}`);
  }

  function traverseSubFile(
    subData: FileInfo[],
    parentPath: string
  ): DefaultTheme.SidebarItem[] {
    return subData.map((file) => {
      const filePath = `${parentPath}/${file.name}`;
      const fileName = file.title || file.name.replace(".md", "");
      if (file.isFolder) {
        return {
          text: fileName,
          collapsed: file.collapsed ?? false,
          items: traverseSubFile(file.children, filePath),
        };
      } else {
        return { text: fileName, link: filePath.replace(".md", "") };
      }
    });
  }

  return sidebar;
}

热更新

VitePress plugin.ts 源码:src\node\plugin.ts handleHotUpdate 钩子中,监听了配置文件,当配置文件修改后会执行 recreateServer 方法重新创建服务,完成刷新

我们可以利用这点,监听到文件增删时触发配置文件的更新来完成开发服务器的热更新

configureServer({ config, watcher }: ViteDevServer) {
  const {
    vitepress: { configPath },
  } = config as ResolvedConfig & { vitepress: SiteConfig };

  // 从config中获取配置文件路径
  const $configPath =
    configPath?.match(/(\.vitepress.*)/)?.[1] || ".vitepress/config.ts";

  // VitePress 中已经添加了对所有 md 文件的监听,这里只需要处理事件
  watcher.on("all", (event, path) => {
    // 过滤掉 change 事件和非 md 文件操作
    if (event === "change" || !path.endsWith(".md")) return;
    // 修改配置文件系统时间戳,触发更新
    fs.utimesSync($configPath, new Date(), new Date());
  });
}

打包发布

插件内容较为简单,我们使用 rollup 打包,需要解析 ts 代码,并进行简单的压缩混淆即可

pnpm i -D rollup rollup-plugin-typescript2 @rollup/plugin-terser

在根目录添加 rollup.config.js 文件:

import typescript from "rollup-plugin-typescript2";
import terser from "@rollup/plugin-terser";

export default {
  // 指定入口
  input: "./src/index.ts",
  // 因为是在 vitepress 中使用,所以仅生成 esm 文件就够了(vite 默认仅支持 esm)
  output: {
    file: "./dist/index.mjs",
    format: "esm",
  },
  // 使用 typescript 解析插件,和代码混淆插件
  plugins: [typescript(), terser()],
};

在 package.json 中添加打包命令:

"scripts": {
  "build": "rollup -c"
},
  // ...

添加后执行 pnpm build 即可完成打包,会在 dist 目录下生成 index.d.tsindex.mjs 文件。完成后我们还需要在 package.json 中指定模块解析相关的字段,这样用户安装后才能正常导入使用

// 定义导入时的模块路径和类型定义路径
"exports": {
  ".": {
    "types": "./dist/index.d.ts",
    "import": "./dist/index.mjs"
  }
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
// 作为依赖包被安装时仅包含 dist 文件夹,package.json、README、LICENSE/LICENCE、main字段文件会始终包含
"files": [
  "dist"
],
// ...

自此插件就算开发完成了,可以开始发布到 npm ,需要注意发布插件时需要先将 npm 源切换到官方源

首次发布时需要先 npm login 登录账号,并设置双因素身份验证( Two-Factor Authentication ),之后通过 npm publish 命令发布。发布相关的操作不做过多介绍,还不了解的话可以自行搜索

完整的代码请在 Github 仓库中查看

转载自:https://juejin.cn/post/7283060133646975012
评论
请登录