用 Astro Content Collections 打造 Markdown 部落格
從零開始設定 Astro 的內容集合,定義 frontmatter schema、產生文章與標籤頁面,並加上程式碼語法高亮。
Astro 的 Content Collections 讓你用型別安全的方式管理 Markdown 文章。這篇文章記錄我從零建立一個部落格的完整流程:定義 schema、建立動態路由、處理標籤頁,最後加上語法高亮。整篇會用到 astro:content 提供的 getCollection() 與 render() 兩個核心 API。
事前準備
先確認 Node.js 版本在 22 以上,然後建立專案並安裝必要套件:
mkdir my-blog && cd my-blog
npm init -y
npm install astro @astrojs/rss @astrojs/sitemap
接著在 package.json 補上幾個常用指令:
{
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
}
}
目錄結構
一個最小可行的結構大致像這樣:
src/
├── content.config.ts # 內容集合的定義
├── content/posts/ # Markdown 文章
├── layouts/BaseLayout.astro
└── pages/
├── index.astro
├── posts/[slug].astro
└── tags/[tag].astro
content.config.ts 必須放在 src/ 底下,Astro 會自動讀取它。
定義內容集合
撰寫 schema
Astro 使用 Zod 來驗證 frontmatter。每篇文章至少要有標題、摘要、日期與標籤:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ base: './src/content/posts', pattern: '**/*.md' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { posts };
這裡的 z.coerce.date() 很重要:YAML 裡寫 2026-08-05 會被解析成字串或日期物件,coerce 能把兩種情況都轉成 Date。
撰寫第一篇文章
在 src/content/posts/hello-world.md 放入:
---
title: 哈囉,世界
description: 這是第一篇文章。
pubDate: 2026-08-01
tags: [hello]
---
內文從這裡開始。
檔名 hello-world 會成為這篇文章的 id,也就是網址中的 slug。
建立頁面
文章列表
在首頁用 getCollection() 拿到所有文章,依日期排序:
---
import { getCollection } from 'astro:content';
const posts = (await getCollection('posts')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.id}/`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('zh-TW')}
</time>
</li>
))}
</ul>
文章內頁
動態路由檔 src/pages/posts/[slug].astro 透過 getStaticPaths() 為每篇文章產生靜態頁面,再用 render() 取得可渲染的 Content 元件:
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('posts');
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>
標籤頁
標籤頁的做法一樣,只是 getStaticPaths() 要先把所有標籤去重:
export async function getStaticPaths() {
const posts = await getCollection('posts');
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: { posts: posts.filter((p) => p.data.tags.includes(tag)) },
}));
}
如果標籤含有中文,Astro 會正確產生對應的資料夾;連結時記得用 encodeURIComponent() 處理。
語法高亮
Astro 內建 Shiki,預設就會高亮所有 fenced code block。若想同時支援淺色與深色主題,可以在 astro.config.mjs 指定兩組主題:
import { defineConfig } from 'astro/config';
export default defineConfig({
markdown: {
shikiConfig: {
themes: { light: 'github-light', dark: 'github-dark' },
},
},
});
然後在全域 CSS 加上深色模式的切換規則:
@media (prefers-color-scheme: dark) {
.astro-code,
.astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}
Shiki 會把兩組顏色都寫進行內樣式,CSS 只需要在深色模式下改用 --shiki-dark 那一組變數。
針對中文的排版調整
英文網站的預設值套到中文上通常會太擠。我實際採用的數值:
| 屬性 | 建議值 | 說明 |
|---|---|---|
font-size |
17px | 中文筆畫多,略大一點較好讀 |
line-height |
1.8 至 1.9 | 英文常用的 1.5 對中文太緊 |
letter-spacing |
0.01em 至 0.02em | 微調字距,不要過大 |
| 段落間距 | 1.25em | 段落之間要有明確的呼吸空間 |
另外記得設定 overflow-wrap: anywhere,避免長網址在手機上把版面撐開。
小結
到這裡,一個能跑、有型別檢查、有語法高亮的 Markdown 部落格就完成了。執行 npm run build 會在 dist/ 產出純靜態檔案,直接丟到 Cloudflare Pages 或任何靜態主機都能上線。