Next.js(v13+) 创建 MDX 博客

TOC

简介

在本文中,我们将基于Next.js(v13+)分别介绍两种搭建MDX博客应用的方法,分别是@next/mdxnext-mdx-remote他们有各自的优缺点,可以根据自身情况选择使用那一种方式。

名称差异描述
@next/mdxNext.js官方提供的markdown 和 MDX解决方案,它从本地文件获取数据,允许您直接在/pages/app目录中创建带有扩展名.mdx的页面。对于简单内容页面来说相对实用。
next-mdx-remote不处理从源加载内容,无论是本地还是远程,因此需要我们自己编写代码实现,但也因此相对灵活,在处理过程中需要配合相关插件来实现内容转换处理,如:gray-matter等。

好了,让我们开始真正的MDX应用搭建之旅吧!

准备

确保已经使用create-next-app创建了一个基础应用,若没有,请先运行以下代码进行创建:

pnpm dlx create-next-app@latest

根据命令行提示,选择您喜欢的配置,在本示例流程中我们选择如下:

What is your project named? next-mdx-app
Would you like to use TypeScript? No / Yes√
Would you like to use ESLint? No / Yes√
Would you like to use Tailwind CSS? No / Yes√
Would you like to use `src/` directory? No√ / Yes
Would you like to use App Router? (recommended) No / Yes√
Would you like to customize the default import alias (@/*)? No / Yes√
What import alias would you like configured? @/*

选择Tailwind CSS是为了方便后续页面排版,当然也可以根据您的喜好不选择。

快捷浏览:Next mdxNext mdx remote

Next mdx

安装渲染MDX所需的软件包

pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

在您应用的根目录下(/app/src目录的父级目录),创建一个mdx-components.tsx文件:

Note

没有这个文件在App Router模式下将无法正常运行。如果使用Pages Router则可忽略这一步。

import type { MDXComponents } from 'mdx/types'
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {...components }
}

更新项目根目录下的next.config.js文件,将其配置为使用MDX

const withMDX = require('@next/mdx')()
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions`` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}
 
module.exports = withMDX(nextConfig)

然后,在您项目的/app目录下创建一个MDX页面:

your-project
├── app
│   └── my-mdx-page
│       └── page.mdx
└── package.json

现在,在其它地方创建一个react组件my-components.tsx,然后就可以直接在my-mdx-page/page.mdx文件中直接使用markdown和导入创建的react组件

import { MyComponent } from 'my-components'
 
# Welcome to my MDX page!
 
This is some **bold** and _italics_ text.
 
This is a list in markdown:
 
- One
- Two
- Three
 
Checkout my React component:
 
<MyComponent />

导航到/my-mdx-page路由,将看到您所创建的MDX页面了。

以上即为@next/mdx官方实现方式,非常简单。但相对也有一定局限情,因为它只处理本地的MDX页面,需要以Next.js路由的方式来管理MDX文章内容。

Next mdx remote

next-mdx-remote允许您在其它地方动态加载markdownMDX内容文件,并在客户端上正确渲染的轻型实用程序。

next-mdx-remote

添加文章内容

/posts目录中创建几个markdown文件,并向这些文件添加一些内容。如下是一个/posts/post-01.md示例:

---
title: My First Post
date: 2022-02-22T22:22:22+0800
---

This is my first post ...

在此目录中将有三个帖子示例:

posts/
├── post-01.md
├── post-02.md
└── post-03.md

解析内容

安装MDX解析所需的软件包

pnpm add next-mdx-remote gray-matter

创建 posts 资源获取/lib/posts.ts文件: 在这里我们需要使用gray-matter插件来解析 markdown 内容。

import fs from "fs";
import { join } from "path";

import matter from "gray-matter";
const postsDir = join(process.cwd(), "posts");

type MetaData = {
  title: string;
  date: Date;
  category: string;
  tags?: string[];
  description?: string;
  draft?: boolean;
};

// 根据文件名读取 markdown 文档内容
export function getPostBySlug(slug: string) {
  const realSlug = slug.replace(/\.md$/, "");

  const fullPath = join(postsDir, `${realSlug}.md`);

  const fileContents = fs.readFileSync(fullPath, "utf8");

  // 解析 markdown 元数据
  const { data, content, excerpt } = matter(fileContents, {
    excerpt: true,
  });

  // 配置文章元数据
  const meta = { ...data } as MetaData;

  return { slug: realSlug, meta, content, excerpt };
}

// 获取 /posts文件夹下所用markdown文档
export function getAllPosts() {
  const slugs = fs.readdirSync(postsDir);

  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    // 排除草稿文件
    .filter((c) => !/\.draft$/.test(c.slug));
    // .filter((c) => !c.meta.draft);
  return posts.sort((a, b) => +b.meta.date - +a.meta.date);
}

添加网站代码

创建/app/posts/page.tsx用于展示所有Post文章列表。

import Link from "next/link";

import { getAllPosts } from "@/lib/posts";

export default async function Posts() {
  const posts = await getAllPosts();

  return (
    <div className="prose grid gap-9 m-auto">
      {posts?.map((post: any) => (
        <Link
          href={`/posts/${post.slug}`}
          className="group font-normal overflow-hidden cursor-pointer no-underline transition fade-in-up "
          key={post.slug}
        >
          <div className="text-xl text-gray-600 group-hover:text-brand truncate ease-in duration-300">
            {post.meta?.title}
          </div>
          <time className="text-gray-400 text-sm leading-none flex items-center">
            {post.meta?.date?.toString()}
          </time>
        </Link>
      ))}
    </div>
  );
}

运行Next.js开发服务,并访问localhost:3000/posts查看文章列表。

pnpm dev

添加Post布局

创建文章呈现页面/app/posts/[slug]/page.tsx

import { MDXRemote } from "next-mdx-remote/rsc";

import { getPostBySlug, getAllPosts } from "@/lib/posts";

type Props = {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

async function getPost(params: Props["params"]) {
  const post = getPostBySlug(params.slug);
  return { post };
}

export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = getAllPosts();

  return posts.map((post) => ({ slug: post.slug }));
}

export default async function Post({ params }: Props) {
  const { post } = await getPost(params);

  return (
    <>
      <h1 className="text-2xl">{post.meta.title}</h1>
      <time className="text-gray-600">{post.meta?.date.toString()}</time>
      <MDXRemote source={post.content} components={{}} options={{}} />
    </>
  );
}

引用组件

创建一个MDX使用的组件/app/posts/[slug]/mdx/Button.tsx

"use client";

import { useState } from "react";

export default function Button({ text }: { text: string }) {
  const [toggle, setToggle] = useState(false);

  return (
    <button onClick={() => setToggle(!toggle)}>
      {toggle ? text : "Click Me"}
    </button>
  );
}

Note

App Router中,需对客户端渲染组件添加use client;

在文章呈现页面/app/posts/[slug]/page.tsx中引入创建的组件

import { MDXRemote, MDXRemoteProps } from "next-mdx-remote/rsc";

import { getPostBySlug, getAllPosts } from "@/lib/posts";

+ import Button from "./mdx/Button";

...

export default async ({ params }: Props) => {
  const { post } = await getPost(params);

  return (
    <>
      ...
+     <MDXRemote source={post.content} components={{Button}} options={{}} />
    </>
  );
};

然后,在/posts文件夹中的文章中使用定义的Button组件

---
title: My First Post
date: 2022-02-22T22:22:22+0800
---

This is my first post ...

+ <Button text="Button" />

现在,导航到/posts/post-01,将看到一个带有一个按钮的可交互的Markdown文档。🎉🎉🎉🎉🎉

扩展

在解决MDX内容呈现后,我们可能还需要对MDX文档内容的frontmatter数据提取、表格、目录、阅读时间、字数统计以及代码内容美化等操作。此时,我们需要用到remarkrehype生态中的一些插件,使用方式也很简单。参见如下配置:

Next mdx

布局

@next/mdx中处理MDX页面布局与常规Next.js页面布局一样,在当前页面目录下(或其父目录下)创建一个layout.tsx文件,然后编写布局代码即可。

元数据

@next/mdx中处理页面元数据时,我们需要自己创建一个相对应的元数据处理组件例如:

type FrontmatterProps = {
  date: string;
  author: string;
  // 其它元数据,如分类、标签、来源、阅读时长等
};

export default function Frontmatter({ date, author }: FrontmatterProps) {
  return (
    <div className="frontmatter">
      date: <time>{date}</time>
      author: {author}
    </div>
  );
}

然后,在page.mdx页面中合适的位置放入该组件,并配置上元数据即可。例如:

import MyComponent from './my-components'
+ import Frontmatter from './frontmatter'

# Welcome to my MDX page!

+ <Frontmatter date="2023-12-12 12:12:12" author="Qhan W"/>
 
This is some **bold** and _italics_ text.
 
This is a list in markdown:
...

官方元数据处理:frontmatter

MDX插件配置

@next/mdxnext-mdx-remote中都可以通过remark插件rehype来转换 MDX 内容。例如,使用remark-gfm来实现 GitHub Flavored Markdown 来支持。

@next/mdx

Note

由于remark和rehype生态系统仅是 ESM,因此,需要将配置文件next.config.js改为next.config.mjs。插件配置如下:

// next.config.mjs
import remarkGfm from 'remark-gfm'
import createMDX from '@next/mdx'
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions`` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}
 
const withMDX = createMDX({
  // Add markdown plugins here, as desired
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [],
  },
})
 
// Merge MDX config with Next.js config
export default withMDX(nextConfig)

next-mdx-remote

import { MDXRemote, MDXRemoteProps } from "next-mdx-remote/rsc";

import remarkToc from "remark-toc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";

const options: MDXRemoteProps["options"] = {
  mdxOptions: {
    remarkPlugins: [[remarkToc, { maxDepth: 4 }], remarkGfm],
    rehypePlugins: [rehypeSlug],
  },
};

export default function MDXContent(props: Pick<MDXRemoteProps, "source">) {
  return (
    <article className="fade-in-up-content prose prose-gray">
      <MDXRemote source={props.source} options={options} />
    </article>
  );
}

代码高亮

在作为技术开发为主的博客中,常常会用到代码示例,这里推荐使用Anthony Fu@shikijs/rehype插件,按插件配置配置即可。其它优秀的代码高亮插件如下:

阅读时间

通过reading-time可以为我们的文章添加阅读时间、文章字数元数据。在/lib/posts.ts文件中作如下修改也可为next-mdx-remote添加文章阅读时长数据:

...
+ import readingTime from "reading-time";

const postsDir = join(process.cwd(), "posts");

+ type ReadingTime = {
+  text: string;
+  minutes: number;
+  time: number;
+  words: number;
+ };

type MetaData = {
...
+  readingTime?: ReadingTime;
};

export function getPostBySlug(slug: string) {
...
  const { data, content, excerpt } = matter(fileContents, {
    excerpt: true,
  });

+ const readTime = readingTime(content);
+ const meta = { ...data, readingTime: readTime } as MetaData;
  ...
}

...

Table of Content

在本文介绍的三个方法中,我们可以通remark-toc插件得到文章的目录。但目录的位置在文章中配置的地方显示,这可能不符合我们预期,在此情况下,可通过样式将目录放置合适合的位置,如:

该样式将目录放在文章右侧,并在小屏幕中隐藏。

#toc {
  display: none;
}

#toc + ul {
  display: none;
  position: fixed;
  right: 16px;
  top: 115px;
  margin: 0;
  padding: 0;
  max-width: 160px;
  max-height: 480px;
  overflow: auto;

  &::before {
    display: table;
    content: "Table of Contents";
    color: rgba(42, 46, 54, 0.45);
    font-weight: bold;
  }
}

#toc + ul,
#toc + ul ul {
  list-style-type: none;
  font-size: 14px;
  margin: 0;

  > li > a {
    text-decoration: none;
    color: rgb(55, 65, 81);
    font-weight: normal;
  }
}

@media (min-width: 1024px) {
  #toc + ul {
    display: block !important;
  }
}

.prose .shiki {
  font-family: DM Mono, Input Mono, Fira Code, monospace;
  font-size: 0.92em;
  line-height: 1.4;
  // margin: 0.5em 0;
}

// TODO: shikiji 未对纯文本样式做适配
.prose .shiki.nord[lang=plaintext] :where(code) {
  color: #d8dee9ff;
}

异常处理

时间格式化

因为我们使用Next.js来搭建博客,并采用服务端渲染方式,因此,在文章内容的发布时间与编辑时间上,需要带上时区信息。否则,在渲染时会出现服务器与客户端时区不一致,导致时间错误问题。对于时间的格式化处理,此处统一采用客户端渲染方式。具体请查看SSR Timezone

插件异常

主要为remark-gfm插件错误。撰写本示例时,正值remarkjs相关插件升级中,因些,在使用next-mdx-remote时出现渲染错误,此时,我们只需回退remark-gfm到上一个大版本即可,即: v3.x。

VS Code TS错误

表现为@next/mdx下,page.mdx出现ts检查错误,重启编辑器即可。

相关链接

最近修改时间:2024-05-29 12:03:31