The Own Lab The Own Lab

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資料來源——從哪裡載入內容
schemaZod 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() 回傳:

屬性型別說明
ContentAstro 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]
階段ASTPlugin 類型處理對象
Markdown → ASTmdastRemarkMarkdown 語法(數學、alerts)
AST → HTMLhastRehypeHTML 結構(slug、連結、KaTeX)

常用 plugin 組合:

Plugin類型功能
remark-mathRemark解析 $...$$$...$$ 數學語法
remark-github-alertsRemark解析 > [!NOTE] 等 callout
rehype-slugRehype自動為標題加上 id
rehype-autolink-headingsRehype標題加上錨點連結
rehype-katexRehype把數學 AST 渲染為 KaTeX HTML
rehype-external-linksRehype外部連結加上 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##

Single Choice

Content Collections 的 schema 驗證發生在什麼時候?

Single Choice

Astro v5 中,glob loader 取代了什麼?

Single Choice

render() 函式回傳的 headings 可以用來做什麼?

Single Choice

Remark 和 Rehype plugin 的差異是什麼?

Single Choice

以下哪種方式不是 Astro Content Layer 內建的 loader?

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 設定,可透過 components prop 全域替換 HTML 元素

留言 (0)

登入後即可留言