用 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 或任何靜態主機都能上線。