Astro 架站筆記(三):如何製作文章列表?簡介 Content Collections

前一篇文章我們討論了如何透過 Astro 的 Layouts 並使用 Markdown 來建立文章,而這些文章如果是放在 src/pages/ 下,則能夠自動被 Astro 產生對應的 URL,進而能在瀏覽器中讀起。

但是要建立部落格,則勢必要有個入口的頁面來羅列這些文章的標題和連結,如果要每次新增一篇文章的時候都修改這個入口頁面,顯然不太方便,偶爾新刪修文章時忘了修改這個頁面,標題連結等資訊就會對不上了。

一個合格的 Static Site Generator 都會包含「取得文章列表」的功能,那就讓我們來看看 Astro 是怎麼做到這件事的。

Import Meta Glob

先來看看最簡單的方式,在 Astro 官方的 Create a blog post archive 中就有這樣的教學:透過 import.meta.glob() 來取得列表。

我們可以先在 pages/posts 底下建立幾篇 Markdown 所撰寫的文章,然後新增一個我們要作為入口頁面的檔案為 posts.astro,架構大致如下:

1
2
3
4
5
6
7
8
├── src
│ ├── pages
│ │ ├── index.astro
│ │ ├── posts
│ │ │ ├── post-1.md
│ │ │ ├── post-2.md
│ │ │ └── post-3.md
│ │ └── posts.astro

接著修改 posts.astro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
---
import { getCollection } from 'astro:content';
const posts = Object.values(import.meta.glob('./posts/*.md', { eager: true }));
---

<body>
<h1>部落格文章總覽</h1>
<ul>
{ posts.map((post: any) => (
<li>
<a href="{post.url}">{post.frontmatter.title}</a>
</li>
)) }
</ul>
</body>

這裡的重點就在於 import.meta.glob('./posts/.md') 這段,可以將所有 ./posts/ 底下的檔案匯入 Array 中,而 Astro 是透過 Vite 來建置網站,import.meta.glob 就是 Vite 所提供的 Glob Import API,用來找出符合條件的檔案。

不單單只是 md 的副檔名,也可以透過 .{md,mdx,tsx} 的方式來一次讀取不同種類的檔案,以及設定 Lazy Loading 或 Eager Loading(一次載入或分批載入。但是在 SSG, Static Site Generator 的情境下,由於都是產出靜態檔案,等同於使用 Eager Loading)等等。

然而這樣的 API 有個比較重大的的缺點,如果你習慣於寫 TypeScript 的話,就要手動寫型別來防呆。接下來要談的另外一種方式,就是比較建議的 API - getCollection()

Content Collections

Astro 官方文件 Content collections 中,也明確指出這種方式因為有 TypeScript 來輔助型別的檢查,是管理相同架構資料的最佳策略,雖然在初期的設定比 Glob Import 的方式多了一些,但是後續的管理就安全有效的多。

那麼,何謂 Content collections?即是 Astro 用來管理有結構資訊的功能。Collections 是存在專案某目錄下的一群結構化資料,包含 Markdown、MDX、YAML、JSON 等,也可以是外部如資料庫、CMS 等資料源。

要實際使用 Content collections 來建立部落格文章列表,並能從列表連結進入內文,我們需要新增以下資料夾及檔案:

1
2
3
4
5
6
7
8
src/
├── content/
│ └── blog/
│ ├── first-post.mdx #3
│ └── second-post.mdx
├── pages/
│ └── blog/[id].astro #2
│ content.config.ts #1

首先第一個是 content.config.ts,在 Astro v5 之前可以統一放到 src/content/ 底下,但是新版本的 Astro 則必須在 src/ 下直接建立 content.config.ts(雖然目前有向下兼容的機制)。

這個檔案中需要定義 Collection 的 Loader(必填)及 Schema(選填,但強烈建議使用),內容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
publishedTime: z.string(),
updatedTime: z.string(),
}),
});

export const collections = { blog };

我們在透過 glob 這個 Loader 將某目錄下的 mdx 都取出,並定義了這些檔案都有含 title 等資訊的 Schema。

其中 z. 開頭的是 Astro 所使用的 Zod 這個 Library,目的是作為 Schema 的驗證工具,例如在建置階段的 Markdown 中的 Frontmatter 進行驗證(純 TypeScript 僅在編譯階段檢查,在建置時:例如動態讀取檔案後就無法做檢查)。

再來是第二點是 src/pages/blog/[id].astro,這是產出每篇部落格文章的模板,其中 [id].astro 中的 id 是每個 Collection entry 的唯一值,預設為檔名(以我們的例子就有 first-postsecond-post),實際內容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
---
import { getCollection, render } from 'astro:content';

export const getStaticPaths = async () => {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
};

const { post } = Astro.props;
const { Content } = await render(post);
---

<html>
<head>
<meta charset="UTF-8" />
<title>{post.data.title}</title>
</head>
<body>
<article>
<h1>{post.data.title}</h1>
<content />
</article>
</body>
</html>

我們首先需要 Export 一個稱作 getStaticPaths() 的函式,回傳一個包含 paramsprops 的陣列,裡面傳入 id 和 post 內容。

Astro 會使用 getStaticPaths() 來建立每篇文章的靜態路由,由於放置在 src/pages 底下的內容會自動被 Astro 產出 URL,[id].astro 就會是每篇文章的路徑,如 /blog/first-post。有了 URL 後,再來就是把 post 中的內容透過 render(post) 得到能渲染的資訊就完成了。

如此一來,我們只要在第三點的 src/content/blog 中繼續新增 .mdx 的文章內容,專注在內容的創作本身即可。