TL;DR

Content Collections 不只是读 Markdown 文件。掌握动态路由、分页和 schema 校验后,你可以构建比传统博客更灵活内容架构—系列文章、关联推荐、自动分页列表,全部类型安全。

超越基础:Content Collections 能做什么

基础用法:定义 schema → 写 Markdown → getCollection() 获取。

高级用法:

  • 自定义 slug 映射(文件名和 URL 路径不一致)
  • 分页(每页 N 篇,自动生成多个页面)
  • 跨 collection 引用(文章关联项目、作者关联文章)
  • 条件内容校验(series 文章必须有 seriesOrder)
  • 自定义渲染管线

动态路由:slug 映射

默认行为

文件 src/content/blog/my-post.md → slug my-post → URL /blog/my-post

自定义 slug

Astro 6.x 允许在 frontmatter 中指定 slug

// content.config.ts
const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    slug: z.string().optional(),  // 自定义 slug
    // ...
  }),
});
---
title: 'My Post'
slug: 'custom-url-path'
---

然后在动态路由页面中读取:

---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { 
      slug: post.data.slug || post.id.replace(/\.md$/, '') 
    },
    props: { post },
  }));
}
---

嵌套目录路由

文件 src/content/blog/tutorials/astro-setup.md 默认 slug 是 tutorials/astro-setup

如果你想去掉目录层级:

// content.config.ts
const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    slug: z.string().optional(),
  }),
});

然后在 frontmatter 中设置 slug: 'astro-setup'

分页:每页 N 篇

Astro 内置 paginate() 函数:

---
// src/pages/blog/[page].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths({ paginate }) {
  const posts = await getCollection('blog');
  const sorted = posts.sort((a, b) => 
    b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
  return paginate(sorted, { pageSize: 10 });
}

const { page } = Astro.props;
---

<!-- 文章列表 -->
{page.data.map(post => (
  <article>
    <a href={`/blog/${post.slug}`}>
      <h2>{post.data.title}</h2>
      <time>{post.data.pubDate.toLocaleDateString()}</time>
    </a>
  </article>
))}

<!-- 分页导航 -->
<nav class="pagination">
  {page.url.prev && <a href={page.url.prev}>上一页</a>}
  <span>{page.currentPage} / {page.lastPage}</span>
  {page.url.next && <a href={page.url.next}>下一页</a>}
</nav>

分页对象属性

属性类型说明
page.dataT[]当前页文章
page.currentPagenumber当前页码
page.lastPagenumber总页数
page.url.prevstring | undefined上一页 URL
page.url.nextstring | undefined下一页 URL
page.totalnumber总文章数

分页和 slug 路由冲突

/blog/[page].astro/blog/[...slug].astro 可能冲突。解决方案:

把分页放在不同路径:

  • /blog/[...slug].astro:文章页
  • /page/[page].astro:分页列表

或者用条件判断:

export async function getStaticPaths({ paginate }) {
  const posts = await getCollection('blog');
  
  // 文章页
  const postPaths = posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
  
  // 分页路径
  const paginatedPaths = paginate(posts, { pageSize: 10 });
  
  return [...postPaths, ...paginatedPaths];
}

Schema 高级校验

条件校验

系列文章必须有 seriesOrder

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    category: z.string().optional(),
    tags: z.array(z.string()).optional(),
    series: z.string().optional(),
    seriesOrder: z.number().optional(),
  }).refine(
    data => {
      // 如果有 series,必须有 seriesOrder
      if (data.series && !data.seriesOrder) return false;
      return true;
    },
    { message: '系列文章必须指定 seriesOrder' }
  ),
});

联合类型

category 可以是预定义值或自由文本:

const CATEGORIES = ['教程', '技术', '随笔'] as const;

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    category: z.enum(CATEGORIES).or(z.string()),  // 预定义值或任意字符串
    // ...
  }),
});

日期校验

确保 pubDate 不是未来日期:

z.date().max(new Date(), '发布日期不能是未来')

内容关系

关联推荐

基于标签重叠文章推荐:

---
const currentPost = Astro.props.entry;
const allPosts = await getCollection('blog');

// 计算标签重叠度
const related = allPosts
  .filter(p => p.slug !== currentPost.slug)
  .map(p => ({
    post: p,
    overlap: (p.data.tags || []).filter(t => 
      (currentPost.data.tags || []).includes(t)
    ).length,
  }))
  .sort((a, b) => b.overlap - a.overlap)
  .slice(0, 5);
---
<aside>
  <h3>相关文章</h3>
  {related.map(({ post, overlap }) => (
    <a href={`/blog/${post.slug}`}>
      {post.data.title} ({overlap} 个共同标签)
    </a>
  ))}
</aside>

系列导航

同系列文章按 seriesOrder 排序:

---
const seriesPosts = allPosts
  .filter(p => p.data.series === currentPost.data.series)
  .sort((a, b) => a.data.seriesOrder - b.data.seriesOrder);

const currentIndex = seriesPosts.findIndex(p => p.slug === currentPost.slug);
const prev = seriesPosts[currentIndex - 1];
const next = seriesPosts[currentIndex + 1];
---

<nav class="series-nav">
  {prev && <a href={`/blog/${prev.slug}`}>{prev.data.title}</a>}
  <span>{currentIndex + 1} / {seriesPosts.length}</span>
  {next && <a href={`/blog/${next.slug}`}>{next.data.title}</a>}
</nav>

渲染管线定制

自定义 remark 插件

给所有代码块添加复制按钮:

// astro.config.mjs
import { defineConfig } from 'astro/config';

function remarkCodeCopy() {
  return (tree) => {
    // 遍历所有 code 节点,添加复制按钮 HTML
  };
}

export default defineConfig({
  markdown: {
    remarkPlugins: [remarkCodeCopy],
  },
});

自定义渲染输出

获取渲染后 HTML 和元数据:

---
const { Content, headings } = await entry.render();

// headings: [{ depth: 2, slug: '标题', text: '标题' }, ...]
// Content: 渲染后的 HTML 组件
---
<Content />

写在

Content Collections 是 Astro 博客的核心数据层。掌握动态路由、分页、schema 校验和内容关系后,你可以构建比 WordPress 更灵活、比 Hugo 更类型安全内容架构。

延伸阅读

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS