文章內容管理

MDX 語法

#

身為一位開發者,肯定對於 Markdown 不陌生,身為 React 的開發者,肯定也對 Component 不陌生,而 MDX 語法正是 Markdown 與 Component 兩者的結合,它允許我們在 Markdown 語法裡穿插 JSX 語法,這使得 Markdown 速記語法如虎添翼,讓文章可以變得更為豐富多彩,簡單、易用、高度彈性等眾多好處讓我毫無懸念地採用 MDX 作為撰寫文章的主要語法,例如我可以這樣子寫文章:

# Work Experience
<Inline justifyContent="space-between" alignItems="center" gap="s" wrap="wrap">
## Senior Backend Engineer, [ĒSEN Inc.](https://www.linkedin.com/company/eseninc/)
*2022.03 - 2023.01*
</Inline>
- Kicked off both business-facing and customer-facing projects with microservice architecture and domaindriven design (DDD) pattern
- Implemented a reservation system with optimistic lock strategy to prevent double booking
- Implemented the design system Essence to modularize and accelerate frontend development

Frontmatter 語法

#

慣例上使用 Markdown 撰寫部落格文章時都會搭配 語法來補足細節設定,像是文章標題、摘要、發佈日期等,不是文章主體,卻又與文章相關的次要資訊。因此本站使用 MDX 時也應該支援 Frontmatter 語法,一般都是在檔案開頭使用 這個 fenced block 夾住 yaml 語法,然後才是 MDX 語法的內文,例如:

---
title: 文章標題
publishDate: '2023-03-02'
keywords:
- 關鍵字1
- 關鍵字2
---
# Title
paragraph.

網站頁面 vs. 部落格文章

#

一個部落格除了文章以外,其實還會有站內的頁面,像是等,他們也屬於作者所撰寫的內容,因此我希望這些網站頁面與部落格文章在撰寫時的體驗是一致的,都應該使用 MDX 語法來完成,差別在於網站頁面應遵守 Next.js 的檔案結構,而部落格文章則可以儲存於 Source Code 之外,所以我的檔案編排會是如以下所示結構:

personal-blog/
- content/ <--- 部落格文章可以放在 pages 資料夾外
- posts/
- post1.mdx
- post2.mdx
- post3.mdx
- ...
- pages/ <--- 網站頁面遵循 Next.js 的路由規則
- index.mdx
- about-site.mdx
- about-author.mdx
- 404.js
- ...
- ...

渲染網站頁面

#

在 Next.js 官方文件 Using MDX with Next.jsopen_in_new 當中已經有詳盡說明如何在你的專案使用 MDX,基本上照著操作就能順利渲染 了,以下是我的設定方式:

  1. 安裝相依

    yarn workspace @app/personal-blog add \
    @mdx-js/loader \
    @mdx-js/react \
    @next/mdx \
    remark-frontmatter \
    remark-gfm
  2. 設定

    personal-blog/next.config.mjs
    import createMDXDecorator from '@next/mdx'
    import { getMdxOptions } from './utils/mdx.mjs'
    const withMDX = createMDXDecorator({
    extension: /\.mdx?$/,
    options: {
    ...getMdxOptions(),
    // If you use `MDXProvider`, uncomment the following line.
    providerImportSource: '@mdx-js/react',
    },
    })
    const nextConfig = {
    transpilePackages: ['@module/utils', '@module/essence'],
    reactStrictMode: true,
    pageExtensions: ['js', 'jsx', 'md', 'mdx'],
    // ...
    }
    export default withMDX(nextConfig)

    此處把 MDX 外掛的設定獨立出來,方便管理及重用:

    personal-blog/utils/mdx.mjs
    import remarkFrontmatter from 'remark-frontmatter'
    import remarkGfm from 'remark-gfm'
    export const getMdxOptions = () => {
    return {
    remarkPlugins: [
    remarkFrontmatter,
    remarkGfm,
    ],
    rehypePlugins: [],
    }
    }
  3. 使用客製化元件

    如果想要客製化渲染 MDX 內容時使用的 Component,首先要準備一個客製元件對照表:

    personal-blog/components/mdx/MDXComponents.js
    import Text from '@module/essence/components/Text' // 使用來自 Design System 的元件
    const MDXComponents = {
    p: (props) => <Text {...props} />,
    }
    export default MDXComponents

    接下來可以在應用程式的最外層注入這些客製化元件:

    personal-blog/pages/_app.js
    import { MDXProvider } from '@mdx-js/react'
    import MDXComponents from '../components/mdx/MDXComponents'
    const MyApp = ({ Component, pageProps }) => {
    return (
    <MDXProvider components={MDXComponents}>
    <Component {...pageProps} />
    </MDXProvider>
    )
    }
    export default MyApp

    如果你的部落格可能需要支援多種風格的客製化元件,也可以考慮把 放在 Layout Component:

    personal-blog/components/layout/SitePageLayout.js
    import { MDXProvider } from '@mdx-js/react'
    import Container from '@module/essence/components/Container'
    import MDXComponents from '../mdx/MDXComponents'
    export default ({ children, ...rest }) => {
    return (
    <Container fluid {...rest}>
    <MDXProvider components={MDXComponents}>{children}</MDXProvider>
    </Container>
    )
    }

    但是要記得在 MDX 頁面使用 來指定 Layout:

    personal-blog/pages/example.mdx
    import SitePageLayout from '../components/layout/SitePageLayout'
    export default ({ children }) => <SitePageLayout>{children}</SitePageLayout>
    # 標題一
    內文。

渲染部落格文章

#

接下來讓我們來看看如何渲染 Source Code 以外的 MDX 檔案。

  1. 路由

    假設我們希望每篇文章的網址形式長成像是 這樣,那麼按照 Next.js 的標準使用方式,我們可以建立頁面 來處理動態的

    personal-blog/pages/blog/[postSlug]/index.js
    import { getPosts } from '../../../utils/post' // 下文將會解釋
    const BlogPostPage = () => {
    // ...
    return
    }
    export async function getStaticPaths() {
    const posts = getPosts()
    return {
    paths: posts.map(post => ({
    params: {
    postSlug: post.slug,
    },
    })),
    fallback: false, // can also be true or 'blocking'
    }
    }
    export default BlogPostPage
  2. 渲染 MDX 內容

    這裡我使用了 這個 Library 來輔助我把 MDX 文字渲染成 React 元件,所以首先安裝這個相依:

    yarn workspace @app/personal-blog add next-mdx-remote

    接著修改 ,將 MDX 原始碼 之後傳遞給 即可渲染出內容了:

    personal-blog/pages/blog/[postSlug]/index.js
    import Container from '@module/essence/components/Container'
    import { MDXRemote } from 'next-mdx-remote'
    import { serialize } from 'next-mdx-remote/serialize'
    import { getMdxOptions } from '../../../utils/mdx.mjs'
    import { getPostByPostSlug, getPosts } from '../../../utils/post' // 下文將會解釋
    const BlogPostPage = ({ post, mdxSource }) => {
    return (
    <Container fluid>
    <h1>{post.frontmatter.title}</h1>
    <MDXRemote {...mdxSource} components={MDXComponents} />
    </Container>
    )
    }
    export async function getStaticPaths() {
    // ...
    }
    export const getStaticProps = async ({ params }) => {
    const { postSlug } = params
    const post = getPostByPostSlug(postSlug)
    const mdxSource = await serialize(post.content, {
    mdxOptions: {
    ...getMdxOptions(),
    format: 'mdx',
    },
    })
    return {
    props: {
    post,
    mdxSource,
    },
    }
    }
    export default BlogPostPage
  3. 使用 讀取 MDX 原始內容

    在上方的程式碼中,我們使用到了兩個 utility functions ,分別可以取得文章列表,以及根據 Slug 取得特定文章。這背後的實作其實只有單純地操作檔案系統,也因此我們的部落格文章儲存位置不必受限於 Next.js 的規範。

    處理 MDX 內容時會需要同時解析 Frontmatter,可以使用 這個 Library:

    yarn workspace @app/personal-blog add gray-matter

    我的 utility function 實作約略如下:

    personal-blog/utils/post.js
    import fs from 'fs'
    import matter from 'gray-matter'
    import path from 'path'
    class BlogPost {
    constructor(absoluteMDXFilePath) {
    this.absoluteFilePath = absoluteMDXFilePath
    this.parsedPath = path.parse(this.absoluteFilePath)
    this.fileContent = fs.readFileSync(this.absoluteFilePath, 'utf-8')
    const parsedFileContent = matter(this.fileContent)
    this.frontMatter = parsedFileContent.data
    this.slug =
    this.frontMatter.index?.partialSlug ||
    this.parsedPath.name.replace(/.mdx$/, '')
    this.content = parsedFileContent.content
    }
    toSerializable() {
    let serialized = {
    slug: this.slug,
    frontMatter: this.frontMatter,
    content: this.content,
    }
    return serialized
    }
    }
    export const getPosts = () => {
    const files = fs.readdirSync(path.join(process.cwd(), 'content/posts'), {
    withFileTypes: true,
    })
    const posts = files.map((file) => {
    let blogPost
    if (file.isDirectory()) {
    blogPost = new BlogPost(
    path.join(process.cwd(), 'content/posts', file.name, 'index.mdx')
    )
    } else {
    blogPost = new BlogPost(
    path.join(process.cwd(), 'content/posts', file.name)
    )
    }
    return blogPost.toSerializable()
    })
    return posts
    }
    export const getPostByPostSlug = (postSlug) => {
    const posts = getPosts()
    const post = posts.find((post) => post.slug === postSlug)
    return post
    }

    使用上需要特別注意,我們可能會使用 來傳遞 post 的 instance 給 React Component,但是只有相容 JSON 的資料格式可以被傳遞,因此 提供了 這個方法來處理 instance 的序列化。