博客路由迁移
在软件开发领域,有一条开发范式,叫做“约定大于配置”。
我之前写文章需要每次新建一个文件都要负责三个模块——文章内容 markdown 编写、模块下文章列表 markdown 编写、侧边栏手动编写。
然而,这和约定式路由十分相似,因此今天做了配置,根据目录来帮我生成这样的侧边栏配置,不需要我再去手写啦。
在 Vite 构建的项目里,我们通过 import.meta.glob
来实现一个手写约定式路由,Webpack 通过 require.context
。而在 Vitepress 会更容易,因为不需要考虑开发环境和生产环境的差异,生成侧边栏配置的过程全部运行在 Node 环境,因此只需如下配置:
ts
import fs from "node:fs";
import path from "node:path";
type SidebarItem = {
text: string;
collapsed: boolean;
items: Array<{
text: string;
link: string;
}>;
};
type Sidebar = Record<string, SidebarItem[]>;
// 解析index.md的标题和列表项
const parseIndexMd = (filePath: string) => {
const content = fs.readFileSync(filePath, "utf-8");
// 去除 Markdown 注释块 <!-- -->
const cleaned = content.replace(/<!--[\s\S]*?-->/g, "");
// 提取index.md的总标题(优先一级标题#,无则用文件名)
const titleMatch = cleaned.match(/# (.+)/);
const title = titleMatch
? titleMatch[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // 移除标题中的链接语法
: path.basename(filePath, ".md");
// 提取所有 "- " 开头的列表项
const hrefList = (cleaned.match(/- (.+)/g) || []).map((item) =>
item.replace(/- /g, ""),
);
return { title, hrefList };
};
/**
* 处理链接路径拼接逻辑
* 确保生成的链接与实际路由路径完全匹配,以支持当前页面高亮
*/
const transformLinkToSidebarItem = (list: string[], basePath: string) => {
return list.map((item) => {
// 匹配 Markdown 链接语法:[文本内容](路径信息)
const linkMatch = item.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
const text = linkMatch[1];
const rawPath = linkMatch[2];
const ext = path.extname(rawPath);
const isMdFile = ext.toLowerCase() === ".md";
// 处理路径:
// 1. .md文件移除扩展名(大多数文档系统会自动处理)
// 2. 其他文件保留完整路径
// 3. 确保路径以/开头,保持一致性
let finalPath = isMdFile ? path.basename(rawPath, ext) : rawPath;
// 组合基础路径和最终路径,确保路径格式统一
let fullLink = `${basePath}/${finalPath}`.replace(/\/+/g, "/");
// 确保链接始终以/开头
if (!fullLink.startsWith("/")) {
fullLink = `/${fullLink}`;
}
return {
text,
link: fullLink,
};
}
// 非Markdown链接处理
let fullLink = `${basePath}/${item}`.replace(/\/+/g, "/");
if (!fullLink.startsWith("/")) {
fullLink = `/${fullLink}`;
}
return {
text: item,
link: fullLink,
};
});
};
// 检测目录类型
const detectDirectoryType = (dirPath: string) => {
const children = fs.readdirSync(dirPath);
const onlySubDirs = children.every((file) =>
fs.statSync(path.join(dirPath, file)).isDirectory(),
);
const hasIndex = children.includes("index.md");
const hasOtherMd = children.some((file) => file.endsWith(".md"));
if (!onlySubDirs && hasIndex) return "single";
if (onlySubDirs) return "double";
if (!onlySubDirs && !hasIndex && hasOtherMd) return "mixed";
return "unknown";
};
// 生成侧边栏项
const generateSidebarItems = (dirPath: string, category: string) => {
const type = detectDirectoryType(dirPath);
const items: SidebarItem[] = [];
if (type === "single") {
const indexPath = path.join(dirPath, "index.md");
const { title, hrefList } = parseIndexMd(indexPath);
items.push({
text: title,
collapsed: false,
items: transformLinkToSidebarItem(hrefList, `/${category}`),
});
} else if (type === "double" || type === "mixed") {
// 处理子目录
const subDirs = fs
.readdirSync(dirPath)
.filter((file) => fs.statSync(path.join(dirPath, file)).isDirectory());
for (const subDir of subDirs) {
const subDirPath = path.join(dirPath, subDir);
const indexPath = path.join(subDirPath, "index.md");
if (fs.existsSync(indexPath)) {
const { title, hrefList } = parseIndexMd(indexPath);
items.push({
text: title,
collapsed: false,
items: transformLinkToSidebarItem(hrefList, `/${category}/${subDir}`),
});
}
}
// 处理混合目录中的单个文件
if (type === "mixed") {
const singleFiles = fs
.readdirSync(dirPath)
.filter((file) => {
const fullPath = path.join(dirPath, file);
return (
fs.statSync(fullPath).isFile() &&
file.endsWith(".md") &&
file !== "index.md"
);
})
.map((file) => {
const filePath = path.join(dirPath, file);
const { title } = parseIndexMd(filePath);
// 确保单个文件链接格式正确
const fileName = path.basename(file, ".md");
let link = `/${category}/${fileName}`;
if (!link.startsWith("/")) {
link = `/${link}`;
}
return {
text: title,
link: link,
};
});
if (singleFiles.length > 0) {
items.push({
text: "碎片集锦",
collapsed: false,
items: singleFiles,
});
}
}
} else {
console.warn(`未知目录类型: ${dirPath}`);
}
return items;
};
// 需要处理的指定目录
const TARGET_DIRECTORIES = ["Life", "Frontend", "Interview", "Softskills"];
const sidebar: Sidebar = {};
const docsPath = path.resolve(__dirname, "../..");
// 遍历目标目录生成侧边栏
TARGET_DIRECTORIES.forEach((category) => {
const categoryPath = path.join(docsPath, category);
if (!fs.existsSync(categoryPath)) {
console.warn(`目录不存在: ${categoryPath}`);
return;
}
if (!fs.statSync(categoryPath).isDirectory()) {
console.warn(`路径不是目录: ${categoryPath}`);
return;
}
// 确保侧边栏键名格式一致,以/结尾
const sidebarKey = `/${category}/`;
sidebar[sidebarKey] = generateSidebarItems(categoryPath, category);
});
// fs write sidebar.json
fs.writeFileSync(
path.join(docsPath, "sidebar.json"),
JSON.stringify(sidebar, null, 2),
);
export { sidebar };