内容系统深度解析

发表于 2025-02-03 00:00 2531 字 13 min read

猪京京 avatar

猪京京

猪京京的数字花园,记录学习、思考和探索的空间

Astro Content Collections 内容系统深度解析

内容系统深度解析

概述

astro-koharu 的内容系统基于 Astro Content Collections,这是 Astro 原生的内容管理方案。它提供了类型安全的内容查询、Markdown/MDX 支持、以及灵活的 Schema 验证。

本项目的内容系统还包含了一套复杂的分类系统,用于处理从 Hexo 迁移过来的多层级分类结构。


Astro Content Collections 基础

什么是 Content Collections?

Content Collections 是 Astro 管理内容的官方方式,它将 Markdown/MDX 文件组织成可查询的集合:

src/content/
├── config.ts          # Schema 定义
└── blog/              # blog 集合
    ├── life/
    │   └── post1.md
    ├── note/
    │   ├── front-end/
    │   │   └── react-learning.md
    │   └── algorithm/
    │       └── sorting.md
    └── weekly/
        └── issue-01.md

核心优势

  1. 类型安全:Schema 验证 + TypeScript 类型推导
  2. 自动解析:Markdown frontmatter 自动转换为对象
  3. 高性能查询:构建时静态生成,无运行时开销
  4. 灵活组织:支持嵌套目录结构

Schema 定义

配置文件 src/content/config.ts

import type { BlogSchema } from 'types/blog';
import { defineCollection, z } from 'astro:content';

const blogCollection = defineCollection({
  schema: z.object({
    // 必填字段
    title: z.string(),              // 文章标题
    date: z.date(),                 // 发布日期

    // 可选字段
    description: z.string().optional(),  // 文章描述/摘要
    link: z.string().optional(),         // 自定义 URL 标识符
    cover: z.string().optional(),        // 封面图片路径
    tags: z.array(z.string()).optional(), // 标签数组

    // Hexo 兼容字段
    subtitle: z.string().optional(),     // 副标题(旧 Hexo)
    catalog: z.boolean().optional(),     // 是否显示目录
    sticky: z.boolean().optional(),      // 是否置顶

    // 分类字段(支持两种格式)
    categories: z
      .array(z.string())                    // 格式1: ['工具']
      .or(z.array(z.array(z.string())))     // 格式2: [['笔记', '前端', 'React']]
      .optional(),
  }) satisfies z.ZodType<BlogSchema>,
});

export const collections = {
  blog: blogCollection,
};

Schema 字段说明

字段类型必填说明
titlestring文章标题
dateDate发布日期
descriptionstringSEO 描述/摘要
linkstring自定义 URL(默认用文件名)
coverstring封面图路径
tagsstring[]标签数组
categories见下文分类(支持多层级)
stickyboolean置顶标记
catalogboolean是否生成目录(Hexo 遗留)
subtitlestring副标题(Hexo 遗留)

分类系统实现

分类格式支持

项目支持两种分类格式,以兼容 Hexo 的历史数据:

# 格式 1:单层分类
categories:
  - 工具

# 格式 2:多层分类(推荐)
categories:
  - [笔记, 前端, React]

这两种格式在代码中统一处理:

// src/lib/content/posts.ts
const firstCategory = categories[0];

if (Array.isArray(firstCategory)) {
  // 格式 2:多层分类 ['笔记', '前端', 'React']
  return firstCategory.includes(categoryName);
} else if (typeof firstCategory === 'string') {
  // 格式 1:单层分类 '工具'
  return firstCategory === categoryName;
}

分类映射 _config.yml

由于 URL 中不能直接使用中文,项目使用映射表将中文分类名转换为英文 slug:

# _config.yml
category_map:
  随笔: life
  笔记: note
  前端: front-end
  React: react
  工具: tools
  周刊: weekly
  # ... 共 22 个分类映射

映射表在 src/constants/category.ts 中被导出:

// src/constants/category.ts
export const categoryMap: Record<string, string> = {
  '随笔': 'life',
  '笔记': 'note',
  '前端': 'front-end',
  // ...
};

分类树结构

分类以树形结构组织,支持无限层级嵌套:

// src/lib/content/types.ts
type Category = {
  name: string;           // 分类名(中文)
  children?: Category[];  // 子分类
};

实际的分类树示例:

笔记
├── 前端
│   ├── JavaScript
│   └── React
├── 后端
├── 算法
└── CS基础
    └── 数据结构

核心函数详解

1. 获取分类列表 getCategoryList()

// src/lib/content/categories.ts
export async function getCategoryList(): Promise<CategoryListResult> {
  const allBlogPosts = await getCollection('blog');
  const countMap: { [key: string]: number } = {};  // 分类文章计数
  const resCategories: Category[] = [];            // 分类树

  for (const post of allBlogPosts) {
    const { catalog, categories } = post.data;
    if (!catalog || !categories?.length) continue;

    const firstCategory = categories[0];

    if (Array.isArray(firstCategory)) {
      // 多层分类:['笔记', '前端', 'React']
      for (let j = 0; j < firstCategory.length; ++j) {
        const name = firstCategory[j];
        countMap[name] = (countMap[name] || 0) + 1;

        // 递归构建分类树
        if (j === 0) {
          addCategoryRecursively(resCategories, [], name);
        } else {
          const parentNames = firstCategory.slice(0, j);
          addCategoryRecursively(resCategories, parentNames, name);
        }
      }
    } else if (typeof firstCategory === 'string') {
      // 单层分类:'工具'
      countMap[firstCategory] = (countMap[firstCategory] || 0) + 1;
      addCategoryRecursively(resCategories, [], firstCategory);
    }
  }

  return { categories: resCategories, countMap };
}

返回值结构

{
  categories: [
    {
      name: '笔记',
      children: [
        { name: '前端', children: [{ name: 'React' }] },
        { name: '算法' }
      ]
    },
    { name: '工具' }
  ],
  countMap: {
    '笔记': 50,
    '前端': 30,
    'React': 15,
    '工具': 10
  }
}

2. 递归添加分类 addCategoryRecursively()

这是构建分类树的核心递归函数:

// src/lib/content/categories.ts
export function addCategoryRecursively(
  rootCategories: Category[],
  parentNames: string[],
  name: string
) {
  if (parentNames.length === 0) {
    // 根分类:直接添加
    const index = rootCategories.findIndex((c) => c.name === name);
    if (index === -1) rootCategories.push({ name });
  } else {
    // 子分类:找到父分类后递归
    const rootParentName = parentNames[0];
    const index = rootCategories.findIndex((c) => c.name === rootParentName);

    if (index === -1) {
      // 父分类不存在,创建
      const rootParentCategory = { name: rootParentName, children: [] };
      rootCategories.push(rootParentCategory);
      addCategoryRecursively(rootParentCategory.children, parentNames.slice(1), name);
    } else {
      // 父分类存在,继续递归
      const rootParentCategory = rootCategories[index];
      if (!rootParentCategory?.children) rootParentCategory.children = [];
      addCategoryRecursively(rootParentCategory.children, parentNames.slice(1), name);
    }
  }
}

执行流程示例

输入: ['笔记', '前端', 'React']

第 1 步: addCategoryRecursively([], [], '笔记')
  → categories = [{ name: '笔记' }]

第 2 步: addCategoryRecursively([], ['笔记'], '前端')
  → categories = [{ name: '笔记', children: [{ name: '前端' }] }]

第 3 步: addCategoryRecursively([], ['笔记', '前端'], 'React')
  → categories = [{
      name: '笔记',
      children: [{
        name: '前端',
        children: [{ name: 'React' }]
      }]
    }]

3. 构建分类路径 buildCategoryPath()

将分类名数组转换为 URL 路径:

// src/lib/content/categories.ts
export function buildCategoryPath(categoryNames: string | string[]): string {
  if (!categoryNames) return '';

  const names = Array.isArray(categoryNames) ? categoryNames : [categoryNames];
  if (names.length === 0) return '';

  const slugs = names.map((name) => categoryMap[name]);
  return '/categories/' + slugs.join('/');
}

// 示例
buildCategoryPath(['笔记', '前端', 'React'])
// → '/categories/note/front-end/react'

buildCategoryPath('工具')
// → '/categories/tools'

从 URL 路径反向查找分类对象:

// src/lib/content/categories.ts
export function getCategoryByLink(
  categories: Category[],
  link?: string
): Category | null {
  const name = getCategoryNameByLink(link ?? '');
  if (!name || !categories?.length) return null;

  for (const category of categories) {
    if (category.name === name) return category;

    // 递归搜索子分类
    if (category?.children?.length) {
      const res = getCategoryByLink(category.children, link);
      if (res) return res;
    }
  }
  return null;
}

文章查询函数

获取排序后的文章 getSortedPosts()

// src/lib/content/posts.ts
export async function getSortedPosts(): Promise<CollectionEntry<'blog'>[]> {
  const posts = await getCollection('blog');

  // 按日期降序排列(最新在前)
  return posts.sort((a, b) => {
    return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
  });
}

获取置顶文章 getPostsBySticky()

// src/lib/content/posts.ts
export async function getPostsBySticky(): Promise<{
  stickyPosts: CollectionEntry<'blog'>[];
  nonStickyPosts: CollectionEntry<'blog'>[];
}> {
  const posts = await getSortedPosts();

  const stickyPosts: CollectionEntry<'blog'>[] = [];
  const nonStickyPosts: CollectionEntry<'blog'>[] = [];

  for (const post of posts) {
    if (post.data?.sticky) {
      stickyPosts.push(post);
    } else {
      nonStickyPosts.push(post);
    }
  }

  return { stickyPosts, nonStickyPosts };
}

获取分类下的文章 getPostsByCategory()

// src/lib/content/posts.ts
export async function getPostsByCategory(categoryName: string): Promise<BlogPost[]> {
  const posts = await getSortedPosts();

  return posts.filter((post) => {
    const { categories } = post.data;
    if (!categories?.length) return false;

    const firstCategory = categories[0];

    // 处理两种分类格式
    if (Array.isArray(firstCategory)) {
      return firstCategory.includes(categoryName);
    } else if (typeof firstCategory === 'string') {
      return firstCategory === categoryName;
    }
    return false;
  });
}

获取系列文章 getSeriesPosts()

系列文章是指同一最深层分类下的所有文章:

// src/lib/content/posts.ts
export async function getSeriesPosts(post: BlogPost): Promise<BlogPost[]> {
  const lastCategory = getPostLastCategory(post);
  if (!lastCategory.name) return [];

  return await getPostsByCategory(lastCategory.name);
}

// 获取文章的最深层分类
export function getPostLastCategory(post: BlogPost): { link: string; name: string } {
  const { categories } = post.data;
  if (!categories?.length) return { link: '', name: '' };

  const firstCategory = categories[0];

  if (Array.isArray(firstCategory)) {
    // ['笔记', '前端', 'React'] → 返回 'React'
    return {
      link: buildCategoryPath(firstCategory),
      name: firstCategory[firstCategory.length - 1],
    };
  } else if (typeof firstCategory === 'string') {
    return {
      link: buildCategoryPath(firstCategory),
      name: firstCategory,
    };
  }

  return { link: '', name: '' };
}

获取相邻系列文章 getAdjacentSeriesPosts()

用于文章页面的”上一篇/下一篇”导航:

// src/lib/content/posts.ts
export async function getAdjacentSeriesPosts(currentPost: BlogPost): Promise<{
  prevPost: BlogPost | null;
  nextPost: BlogPost | null;
}> {
  const seriesPosts = await getSeriesPosts(currentPost);

  if (seriesPosts.length === 0) {
    return { prevPost: null, nextPost: null };
  }

  const currentIndex = seriesPosts.findIndex(
    (post) => post.slug === currentPost.slug
  );

  if (currentIndex === -1) {
    return { prevPost: null, nextPost: null };
  }

  // 因为文章按日期降序排列(最新在前)
  // prevPost 是更新的文章(索引 - 1)
  // nextPost 是更旧的文章(索引 + 1)
  const prevPost = currentIndex > 0 ? seriesPosts[currentIndex - 1] : null;
  const nextPost = currentIndex < seriesPosts.length - 1
    ? seriesPosts[currentIndex + 1]
    : null;

  return { prevPost, nextPost };
}

周刊专栏功能

项目支持特殊的”周刊”分类,与普通文章分开展示:

// src/lib/content/posts.ts

// 获取所有周刊文章
export async function getWeeklyPosts(): Promise<BlogPost[]> {
  const { featuredSeries } = siteConfig;
  if (!featuredSeries?.enabled || !featuredSeries.categoryName) {
    return [];
  }

  return await getPostsByCategory(featuredSeries.categoryName);
}

// 获取最新周刊
export async function getLatestWeeklyPost(): Promise<BlogPost | null> {
  const weeklyPosts = await getWeeklyPosts();
  return weeklyPosts[0] ?? null;
}

// 获取非周刊文章(首页使用)
export async function getNonWeeklyPosts(): Promise<BlogPost[]> {
  const { featuredSeries } = siteConfig;
  if (!featuredSeries?.enabled || !featuredSeries.categoryName) {
    return await getSortedPosts();
  }

  const allPosts = await getSortedPosts();
  return allPosts.filter(
    (post) => !isPostInCategory(post, featuredSeries.categoryName)
  );
}

文章 Frontmatter 示例

基础文章

---
title: React Hooks 学习笔记
date: 2024-01-15
description: 深入理解 React Hooks 的工作原理
tags:
  - React
  - Hooks
  - 前端
categories:
  - [笔记, 前端, React]
catalog: true
---
文章内容...

置顶文章

---
title: 网站公告
date: 2024-03-01
sticky: true
categories:
  - 随笔
---

自定义链接

---
title: 非常长的文章标题
link: short-url
date: 2024-02-20
---
# 访问路径将是 /post/short-url 而不是文件名

数据流图

┌─────────────────────────────────────────────────────────────┐
│                    Markdown 文件                            │
│   src/content/blog/note/front-end/react-hooks.md           │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    Schema 验证                              │
│   src/content/config.ts → z.object({...})                  │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                 Content Collection API                      │
│   getCollection('blog') → CollectionEntry<'blog'>[]        │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    工具函数处理                              │
│   ┌─────────────────┐  ┌─────────────────┐                 │
│   │  posts.ts       │  │  categories.ts  │                 │
│   │  - getSorted    │  │  - getList      │                 │
│   │  - getByCategory│  │  - buildPath    │                 │
│   │  - getSeries    │  │  - getByLink    │                 │
│   └─────────────────┘  └─────────────────┘                 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    页面组件                                  │
│   ┌─────────────────┐  ┌─────────────────┐                 │
│   │  PostList.astro │  │ CategoryList    │                 │
│   │  PostCard.astro │  │ .astro          │                 │
│   └─────────────────┘  └─────────────────┘                 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                    静态 HTML 输出                            │
│   dist/post/react-hooks/index.html                         │
│   dist/categories/note/front-end/react/index.html          │
└─────────────────────────────────────────────────────────────┘

学习要点

  1. Content Collections:Astro 原生内容管理,提供类型安全和 Schema 验证
  2. 双格式分类:兼容 Hexo 的单层分类和多层分类格式
  3. 分类映射:中文分类名 → 英文 slug 的转换机制
  4. 递归算法:分类树的构建和遍历
  5. 工具函数分层
    • posts.ts:文章查询(排序、筛选、分页)
    • categories.ts:分类操作(构建、查找、路径生成)
    • tags.ts:标签统计

相关文件

文件说明
src/content/config.tsSchema 定义
src/content/blog/博客文章目录
src/lib/content/posts.ts文章查询函数
src/lib/content/categories.ts分类处理函数
src/lib/content/tags.ts标签处理函数
src/lib/content/types.ts类型定义
src/constants/category.ts分类映射表
_config.ymlHexo 分类映射源文件
© 2025 - 2026 猪京京 @pigthinktank
Powered by theme astro-koharu · Inspired by Shoka