Astro Content Layer
Content Collections 的 schema 定義、資料查詢與 MDX 整合實務
Overview##
一個文件網站最核心的問題是:如何管理大量的 Markdown / MDX 檔案,並確保每篇文章的 frontmatter 都是正確的?
Astro 的 Content Collections 就是這個問題的解法。它提供:
- Schema 驗證:用 Zod 定義 frontmatter 結構,build 時自動檢查
- Type Safety:查詢結果自動帶有 TypeScript 型別
- Loader 抽象:從檔案系統、JSON、甚至遠端 API 載入內容
graph LR
A[Content Source] -->|Loader| B[Content Layer]
B -->|Schema Validation| C[Type-Safe Data]
C -->|getCollection / getEntry| D[Astro Pages]
D -->|render| E[HTML Output]
Note
Astro v5 重新設計了 Content Layer API——type: 'content' 被替換為 loader,設定檔從 config.ts 改為 content.config.ts。本文以 v5+ 語法為準。
Schema##
定義 Collection###
所有 collection 定義在 src/content.config.ts:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
publishedAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
幾個重點:
| 元素 | 說明 |
|---|---|
loader | 資料來源——從哪裡載入內容 |
schema | Zod schema——定義每筆資料的結構 |
z.coerce.date() | 自動把字串轉成 Date 物件 |
.default([]) | 缺少時使用預設值 |
.optional() | 可省略的欄位 |
Zod Schema 常用模式###
import { z } from 'astro/zod';
// 列舉值
z.enum(['draft', 'published', 'archived']);
// 巢狀物件
z.object({
url: z.string(),
alt: z.string(),
});
// 陣列 + 預設值
z.array(z.string()).default([]);
// 字串轉日期
z.coerce.date();
// 數字 + 預設值
z.number().default(0);
// 條件型別(discriminated union)
z.discriminatedUnion('type', [
z.object({ type: z.literal('video'), videoUrl: z.string() }),
z.object({ type: z.literal('article'), body: z.string() }),
]);
Tip
Schema 驗證發生在 build 時。如果某篇文章的 frontmatter 不符合 schema,build 會直接報錯——這比 runtime 才發現問題好得多。
實務範例###
以下是一個文件網站的 collection 定義,支援四層分類和草稿功能:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const docs = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
field: z.string(), // 頂層分類(如 Software Engineering)
category: z.string(), // 次層分類(如 Frontend)
topic: z.string().optional(), // 側欄分組(如 Astro)
order: z.number().default(0),
draft: z.boolean().default(false),
publishedAt: z.coerce.date().optional(),
updatedAt: z.coerce.date().optional(),
}),
});
export const collections = { docs };
對應的 frontmatter:
---
title: Astro Content Layer
field: Software Engineering
category: Frontend
topic: Astro
order: 6
publishedAt: 2026-04-18
---
如果 title 漏掉或 order 寫成字串,build 時就會報錯。
Loaders##
Loader 決定內容的來源。Astro 內建兩種 loader:
glob — 檔案系統###
從本地目錄載入 Markdown / MDX 檔案:
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({
pattern: '**/*.{md,mdx}', // 匹配模式
base: './src/content/blog', // 基礎目錄
}),
schema: z.object({
/* ... */
}),
});
glob loader 的 id 來自檔案路徑(相對於 base),例如 2026/hello-world。
file — JSON / YAML###
從單一檔案載入結構化資料:
import { file } from 'astro/loaders';
const dogs = defineCollection({
loader: file('src/data/dogs.json'),
schema: z.object({
id: z.string(),
breed: z.string(),
temperament: z.array(z.string()),
}),
});
適用場景:設定檔、翻譯字串、API mock 資料。
自訂 Loader###
可以從任何資料來源載入——API、CMS、資料庫:
const products = defineCollection({
loader: async () => {
const res = await fetch('https://api.example.com/products');
const data = await res.json();
return data.map((item: { id: string; name: string; price: number }) => ({
id: item.id,
data: { name: item.name, price: item.price },
}));
},
schema: z.object({
name: z.string(),
price: z.number(),
}),
});
Warning
自訂 loader 在 SSG 模式下只會在 build 時執行一次。如果資料來源經常變動,考慮使用 output: 'server' 搭配 on-demand 渲染。
Querying##
getCollection — 取得所有項目###
---
import { getCollection } from 'astro:content';
// 取得所有文章
const allPosts = await getCollection('blog');
// 過濾:排除草稿
const publishedPosts = await getCollection('blog', ({ data }) => {
return data.draft !== true;
});
// 排序:依日期降冪
const sortedPosts = publishedPosts.sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()
);
---
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
</li>
))}
</ul>
getEntry — 取得單一項目###
---
import { getEntry } from 'astro:content';
// 用 id 取得特定文章
const post = await getEntry('blog', 'hello-world');
if (!post) {
return Astro.redirect('/404');
}
---
<h1>{post.data.title}</h1>
render — 渲染內容###
render() 把 Markdown / MDX 編譯成可渲染的 <Content /> 元件:
---
import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'hello-world');
const { Content, headings } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
render() 回傳:
| 屬性 | 型別 | 說明 |
|---|---|---|
Content | Astro Component | 渲染後的文章內容 |
headings | { depth, slug, text }[] | 所有標題,可用來產生 TOC |
動態路由###
Content Collections 搭配 getStaticPaths 自動產生頁面:
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
每篇文章自動產生對應的靜態頁面:/blog/hello-world、/blog/astro-guide 等。
MDX Integration##
設定 MDX###
在 astro.config.ts 中加入 MDX 整合:
// astro.config.ts
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [mdx()],
markdown: {
shikiConfig: { theme: 'github-light' },
remarkPlugins: [remarkMath, remarkGithubAlerts],
rehypePlugins: [rehypeSlug, rehypeKatex],
},
});
Note
markdown 設定同時套用到 .md 和 .mdx 檔案。MDX 整合會繼承 markdown 的 remark / rehype plugins。
Remark vs Rehype###
Markdown 的處理流程是一條 pipeline:
graph LR
A[Markdown Source] -->|Parse| B[mdast]
B -->|Remark Plugins| C[mdast - transformed]
C -->|Convert| D[hast]
D -->|Rehype Plugins| E[hast - transformed]
E -->|Stringify| F[HTML]
| 階段 | AST | Plugin 類型 | 處理對象 |
|---|---|---|---|
| Markdown → AST | mdast | Remark | Markdown 語法(數學、alerts) |
| AST → HTML | hast | Rehype | HTML 結構(slug、連結、KaTeX) |
常用 plugin 組合:
| Plugin | 類型 | 功能 |
|---|---|---|
remark-math | Remark | 解析 $...$ 和 $$...$$ 數學語法 |
remark-github-alerts | Remark | 解析 > [!NOTE] 等 callout |
rehype-slug | Rehype | 自動為標題加上 id |
rehype-autolink-headings | Rehype | 標題加上錨點連結 |
rehype-katex | Rehype | 把數學 AST 渲染為 KaTeX HTML |
rehype-external-links | Rehype | 外部連結加上 target="_blank" |
MDX 元件注入###
在渲染 MDX 內容時,可以透過 components prop 替換 HTML 元素:
---
import { getEntry, render } from 'astro:content';
import CustomHeading from '../components/CustomHeading.astro';
import CodeBlock from '../components/CodeBlock.astro';
const post = await getEntry('blog', 'hello-world');
const { Content } = await render(post);
---
<Content components={{ h1: CustomHeading, pre: CodeBlock }} />
這讓你可以在不修改 MDX 原始檔的情況下,全域替換渲染樣式。
Quiz##
Summary##
- Content Collections 用 Zod schema 驗證 frontmatter,build 時自動檢查,提供 type-safe 查詢
- Astro v5 引入 Content Layer API:用
loader(glob、file、自訂)取代舊版的type getCollection()取得所有項目(支援過濾),getEntry()取得單一項目render()把 Markdown / MDX 編譯成<Content />元件,並提供headings陣列- Markdown pipeline:Remark(mdast)→ Rehype(hast)→ HTML,各階段用對應的 plugin
- MDX 整合繼承 markdown 設定,可透過
componentsprop 全域替換 HTML 元素
留言 (0)
登入後即可留言