Day 22 — 路由分类修复:多级嵌套目录的正确提取

修复博客路由系统中因路径解析错误导致 interview 等嵌套目录下的内容无法归入正确分类的问题。将 `parts[length-2]` 统一改为按 `categories` 关键词定位的索引方案,同时补全了缺失分类的中文显示名,确保构建产物路由结构正确。

Day 22 — 路由分类修复:多级嵌套目录的正确提取

背景

博客建站以来,内容按分类组织在 src/content/categories/ 目录下:

src/content/categories/
├── build-log/           ← 只有一层嵌套
├── blog/
├── projects/
├── music/
├── AI/
├── game/Minecraft/      ← 两层嵌套
└── interview/
    ├── GMM Technoworld/ ← 两层嵌套
    ├── Digital Dream/
    └── NCS_QA engineer/

用户发现网站 /blog 页面中没有出现 interview(面试)分类,反而 GMM TechnoworldDigital DreamNCS_QA engineer 等公司名直接作为独立分类出现在博客分类列表中。

期望的行为是:

  • /blog/interview/ → 展示所有面试相关文章
  • /blog/game/ → 展示所有游戏相关文章

问题分析

根因

所有路由文件都使用 parts[parts.length - 2] 提取分类名——即取文件路径的直接父目录

对于 build-log/day21.md

parts = ["..", "..", "content", "categories", "build-log", "day21.md"]
parts[length - 2] = "build-log"  ← ✅ 正确

对于 interview/GMM Technoworld/Flood_Detection_Project_DeepDive.md

parts = ["..", "..", "content", "categories", "interview", "GMM Technoworld", "xxx.md"]
parts[length - 2] = "GMM Technoworld"  ← ❌ 错误(取了公司名而非 interview)

这种写法隐含了一个假设:每个文件只有一层分类嵌套。遇到两层以上的嵌套(interview/公司名/文件.md),取到的永远是倒数第二级目录,而不是真正的顶级分类。

影响范围

目录实际分类数问题
build-log/, blog/, projects/, music/, AI/单层✅ 正常
interview/公司/两层❌ 公司名变成独立分类
game/Minecraft/两层❌ Minecraft 变成独立分类

修复方案

核心思路

不再使用从尾部索引的方式,而是在路径中定位关键词 categories 的索引,取其下一个元素作为分类名:

// 修复前(错误)
parts[parts.length - 2]

// 修复后(正确)
parts[parts.indexOf("categories") + 1]

无论 categories/ 后面有多少层嵌套,indexOf("categories") + 1 永远指向第一级分类目录。

修改文件

5 个文件,每个文件 1-2 处修改:

文件改动位置用途
src/pages/blog/index.astro分类分组统计各分类文章数
src/pages/blog/[category].astrogetStaticPaths + 文章过滤生成分类路由 + 按分类展示文章
src/pages/blog/[category]/[slug].astrogetStaticPaths + 文章匹配生成文章路由 + 匹配文章内容
src/pages/blog/tags/[tag].astro文章分类提取标签页文章列表的链接生成

[category].astro 为例,两处改动完全相同:

  export async function getStaticPaths() {
    const postModules = import.meta.glob("../../content/categories/**/*.md", { eager: true })
    const categorySet = new Set<string>()
    for (const filePath of Object.keys(postModules)) {
      const parts = filePath.split("/")
-     categorySet.add(parts[parts.length - 2])
+     categorySet.add(parts[parts.indexOf("categories") + 1])
    }
    // ...
  }
  for (const [filePath, mod] of Object.entries(postModules)) {
    const parts = filePath.split("/")
-   const cat = parts[parts.length - 2]
+   const cat = parts[parts.indexOf("categories") + 1]
    if (cat !== category) continue
    // ...
  }

补全分类中文名

categoryNames 映射中添加了之前缺失的分类:

{
  "build-log": { zh: "建站日志", en: "Build Log" },
  blog:        { zh: "默认",     en: "Blog" },
  projects:    { zh: "作品集",   en: "Projects" },
  interview:   { zh: "面试",     en: "Interview" },  // ✨ 新增
  game:        { zh: "游戏",     en: "Game" },       // ✨ 新增
  music:       { zh: "音乐",     en: "Music" },       // ✨ 新增
  AI:          { zh: "AI",       en: "AI" },          // ✨ 新增
}

效果验证

执行 npm run build 后,路由结构如下:

✓ /blog/interview/index.html                                          ← 面试分类页
✓ /blog/interview/gmm-technoworld-flood_detection_project_deepdive    ← GMM 文章
✓ /blog/interview/gmm-technoworld-intern_work_analysis
✓ /blog/interview/gmm-technoworld-company_background
✓ /blog/interview/digital-dream-面试错题集                              ← Digital Dream 文章
✓ /blog/interview/digital-dream-mock_interview_feng_yilang
✓ /blog/interview/digital-dream-selfintro_feng_yilang
✓ /blog/interview/digital-dream-interview_checklist
✓ /blog/interview/digital-dream-company_background
✓ /blog/interview/ncs_qa-engineer-面试官背调                            ← NCS 文章
✓ /blog/interview/ncs_qa-engineer-基础问答模拟
✓ /blog/interview/ncs_qa-engineer-实习生实操指南
✓ /blog/interview/ncs_qa-engineer-smart_bakery_技术栈详解
✓ /blog/interview/ncs_qa-engineer-python_项目问答模拟
✓ /blog/interview/ncs_qa-engineer-company_background

✓ /blog/game/index.html                               ← 游戏分类页
✓ /blog/game/minecraft-gtnh                           ← Minecraft 文章

所有 interview 相关内容正确归入 /blog/interview/,不再有公司名作为独立分类的问题。


边界情况

slug 冲突修复

首次修复路由后出现 warning——三篇 COMPANY_BACKGROUND.md 同属 interview 分类,纯文件名 slug 均为 company_background 导致冲突。

修复:在 slug 生成逻辑中引入子目录前缀。核心公式改为:

const slug = parts.slice(parts.indexOf("categories") + 2)
  .map(s => s.replace(/\.md$/, "").toLowerCase().replace(/\s+/g, "-"))
  .join("-")

原理:取 categories/ 之后、文件扩展名之前的所有路径段,各自规范化后以 - 拼接。

对比效果:

文件路径旧 slug新 slug
interview/GMM Technoworld/COMPANY_BACKGROUND.mdcompany_backgroundgmm-technoworld-company_background
interview/Digital Dream/COMPANY_BACKGROUND.mdcompany_backgrounddigital-dream-company_background
interview/NCS_QA engineer/COMPANY_BACKGROUND.mdcompany_backgroundncs_qa-engineer-company_background
interview/Digital Dream/面试错题集.md面试错题集 ⚠️digital-dream-面试错题集
game/Minecraft/GTNH.mdgtnh ⚠️minecraft-gtnh ✅(更有辨识度)
build-log/day21.mdday21day21 ✅(单层路径,不变)

修改了 4 处 slug 生成逻辑(与之前分类提取修改在同一组文件中),构建后 15 个 interview 页面全部唯一,无任何 conflict warning。

路径兼容性

parts.indexOf("categories") 依赖于 glob 路径中一定包含 categories 这个词。由于所有 markdown 文件都通过 "../../content/categories/**/*.md" 匹配,这个条件始终成立,不会出现 -1 索引错误。


文件变更清单

文件操作说明
src/pages/blog/index.astro修改分类分组改用 indexOf("categories") + 1;补全分类中文名
src/pages/blog/[category].astro修改getStaticPaths + 分类过滤 + slug 生成逻辑修正;补全分类中文名
src/pages/blog/[category]/[slug].astro修改getStaticPaths + 文章匹配 + slug 生成逻辑修正;补全分类中文名
src/pages/blog/tags/[tag].astro修改文章分类提取 + slug 生成逻辑修正

学到的概念

概念理解
Astro 文件路由与目录结构解耦Astro 的路由基于文件系统路径(src/pages/blog/[category].astro 对应 /blog/:category),但数据源(content collection 或 glob)的目录结构可以与此不同。路由参数的值getStaticPaths 返回什么决定,而非文件在磁盘上的实际位置
parts[length - 2] 隐含假设从数组尾部索引假设了只有一层嵌套。当目录结构扩展为双层后,这个假设崩塌。从已知锚点(categories)正向索引比从尾部反向索引更健壮
slug 冲突处理Astro 中如果 getStaticPaths 返回了相同的 [category, slug] 对,后定义的会静默覆盖前面的。Astro 会发出 warning 但不会阻止构建。需在设计 slug 生成策略时考虑文件路径的唯一性
Astro 的 getStaticPaths 去重行为getStaticPaths 返回相同 params 的多条记录时,Astro 不会报错(只会 warning),实际生成的是较早的那个条目。观察:遍历 Object.keys(modules) 时,glob 返回顺序即文件系统遍历顺序
import.meta.glob 的路径计算import.meta.glob 的模式匹配路径是相对于当前文件的相对路径。Vite 会在编译时展开 ** 通配符,实际运行时 split("/") 得到的数组长度取决于匹配到的文件在磁盘上的真实路径深度
slug 唯一性设计parts.slice(anchor + 2).join("-") —— 取分类之后的所有路径段规范化后拼接,天然保证唯一性。无论多少层嵌套,都不会出现同名文件 slug 冲突。这个公式同时适用于单层和嵌套路径
92%