如何使用 Next.js 和 MDX 建立部落格

/0 條留言

前言

要用到的 packages 有:

本次教學的 repository:

https://github.com/tszhong0411/nextjs-mdx-blog

樣品

線上

Demo
Demo

如何建立 blog

首先,我們用以下指令建立 Next.js 項目:

yarn create next-app nextjs-mdx-blog

接著,再建立以下檔案:

  • components\Layout.js - 把 components 都包起來,作為 container 的用途 (可選,只是樣式而已)
  • date\blog\*.mdx - 部落格文章
  • lib\formatDate.js - 把日期格式化為 YYYY年MM月DD日
  • [slug].js - 文章頁面,使用 dynamic routes
|-- components
| |-- Layout.js
|-- data
| |-- blog
| |-- markdown.mdx
| |-- nextjs.mdx
| |-- react.mdx
|-- lib
| |-- formatDate.js
| |-- mdx.js
|-- pages
| |-- blog
| |-- [slug].js

如何處理 Markdown 檔案

const root 為根目錄,process.cwd() 方法返回 Node.js 進程的當前工作目錄。

1
const root = process.cwd()

再寫一個變量 POSTS_PATH 為 文章檔案存放的路徑。

1
import path from 'path'
2
3
const POSTS_PATH = path.join(root, 'data', 'blog')
4
// 輸出: A:\nextjs-mdx-blog\data\blog

再用 fs 閱讀讀取該目錄的內容,也即是 data\blog 下的所有檔案名稱。

1
import fs from 'fs'
2
3
export const allSlugs = fs.readdirSync(POSTS_PATH)
4
// 輸出: ['markdown.mdx', 'nextjs.mdx', 'react.mdx']

然後寫一個可以把副檔名移除的功能,等一下會用到。

1
export const formatSlug = (slug) => slug.replace(/\.mdx$/, '')
2
/**
3
* 例如 formatSlug('markdown.mdx')
4
* 輸出: 'markdown'
5
*/

接著是用 slug 取得文章內容。

1
export const getPostBySlug = async (slug) => {
2
const postFilePath = path.join(POSTS_PATH, `${slug}.mdx`)
3
// 輸出: A:\nextjs-mdx-blog\data\blog\slug.mdx
4
const source = fs.readFileSync(postFilePath)
5
// 返回檔案內容
6
7
const { content, data } = matter(source)
8
/*
9
* 例如:
10
* ---
11
* title: Hello
12
* slug: home
13
* ---
14
* <h1>Hello world!</h1>
15
*
16
* 返回:
17
* {
18
* content: '<h1>Hello world!</h1>',
19
* data: {
20
* title: 'Hello',
21
* slug: 'home'
22
* }
23
* }
24
*/
25
26
const mdxSource = await serialize(content)
27
// 把 content 丟到 serialize (next-mdx-remote) 處理
28
29
const frontMatter = {
30
...data,
31
slug,
32
}
33
// 把 slug 也放到 front matter 中,之後會用到
34
35
return {
36
source: mdxSource,
37
frontMatter,
38
}
39
}

然後是取得所有文章,在首頁中顯示。

1
export const getAllPosts = () => {
2
const frontMatter = []
3
4
allSlugs.forEach((slug) => {
5
const source = fs.readFileSync(path.join(POSTS_PATH, slug), 'utf-8')
6
7
const { data } = matter(source)
8
9
frontMatter.push({
10
...data,
11
slug: formatSlug(slug),
12
date: new Date(data.date).toISOString(),
13
})
14
})
15
16
return frontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
17
}
18
19
// 根據日期由大至小排序
20
const dateSortDesc = (a, b) => {
21
if (a > b) return -1
22
if (a < b) return 1
23
24
return 0
25
}

格式化日期

1
export const formatDate = (date) =>
2
new Date(date).toLocaleDateString('zh-TW', {
3
year: 'numeric',
4
month: 'long',
5
day: 'numeric',
6
})
7
/*
8
* formatDate('2022-08-21T00:00:00Z')
9
* 輸出: '2022年8月21日'
10
*/

首頁

1
import { formatDate } from '../lib/formatDate'
2
import { getAllPosts } from '../lib/mdx'
3
4
import Link from 'next/link'
5
6
export default function Home({ posts }) {
7
return (
8
<>
9
<h1 className='text-6xl font-bold mb-8'>Blog</h1>
10
<hr className='my-8' />
11
<ul className='flex flex-col gap-3'>
12
{posts.map(({ slug, title, summary, date }) => (
13
<li key={slug}>
14
<Link href={`/blog/${slug}`}>
15
<a className='border border-solid border-gray-300 rounded-lg shadow-md p-6 block'>
16
<div className='flex justify-between'>
17
<h2>{title}</h2>
18
<time dateTime={date}>{formatDate(date)}</time>
19
</div>
20
<p className='mt-4'>{summary}</p>
21
</a>
22
</Link>
23
</li>
24
))}
25
</ul>
26
</>
27
)
28
}
29
30
// 使用 getStaticProps 取得所有文章
31
export const getStaticProps = async () => {
32
const posts = getAllPosts()
33
34
return {
35
props: {
36
posts,
37
},
38
}
39
}

文章頁面

1
import { formatDate } from '../../lib/formatDate'
2
import { allSlugs, formatSlug, getPostBySlug } from '../../lib/mdx'
3
4
import { MDXRemote } from 'next-mdx-remote'
5
6
export default function Blog({ post }) {
7
const { title, date } = post.frontMatter
8
9
return (
10
<div>
11
<h1 className='font-bold text-6xl mb-2'>{title}</h1>
12
<time dateTime={date} className='text-lg font-medium'>
13
{formatDate(date)}
14
</time>
15
<hr className='my-8' />
16
<article className='prose'>
17
<MDXRemote {...post.source} />
18
</article>
19
</div>
20
)
21
}
22
23
export const getStaticProps = async ({ params }) => {
24
const post = await getPostBySlug(params.slug)
25
26
return {
27
props: {
28
post,
29
},
30
}
31
}
32
33
export const getStaticPaths = async () => {
34
const paths = allSlugs.map((slug) => ({
35
params: {
36
slug: formatSlug(slug),
37
},
38
}))
39
/*
40
* paths 輸出:
41
* [
42
* { params: { slug: 'markdown' } },
43
* { params: { slug: 'nextjs' } },
44
* { params: { slug: 'react' } }
45
* ]
46
*/
47
48
return {
49
paths,
50
fallback: false,
51
}
52
}

這樣簡易的 Blog 就大功告成了。

實用連結

目錄