Tuntun Blog

如何用Nextjs搭建一个基于MDX的博客网站

阅读时间: 11分钟 4秒

介绍

本文将介绍如何搭建一个类似于本博客网站的网站,使用 React、Next.js、Tailwind CSS、Shadcn/ui、MDX 等技术栈。

开发过程中部分代码和思路主要借鉴于两个项目:

项目初始化 & 配置

首先,我们需要初始化一个Next.js项目:

npx create-next-app@latest my-blog

然后,我们需要安装一些开发时需要用到的依赖,比如eslint、prettier等。tailwind在初始化的时候就已经配置好了,所以无需额外安装。

之后初始化一下 shadcn/ui,就可以开始正式开发了。

MDX处理

博客网站最重要的当然就是内容的处理了,我们使用MDX来编辑文章。

MDX是一种将Markdown和JSX结合的语法,可以让我们在Markdown中使用JSX,这样我们就可以在Markdown中使用React组件了,可以说是非常方便和强大。

一开始选择的管理文章内容方案如下:

  • nextra
    • 优点:不需要自己编写解析MDX的代码,直接使用即可,已经有了现成的处理MDX的逻辑,以及渲染后的样式。
    • 缺点:只支持Page Router,不方便对文章内容进行自定义的处理,更适合写文档应用而不是博客应用。
  • contentlayer
    • 优点:将渲染的逻辑交由开发者,本身只负责处理内容,可以更加灵活的处理文章内容。
    • 缺点:需要自己添加插件,编写解析MDX的代码,相对麻烦一些。

最终选择的是contentlayer作为MDX的管理方案,但是contentlayer的主仓库已经很久没更新了,实际安装的是类似于contentlayer分支的另一个库:contentlayer2,详见官方ISSUE

但是其用法和contentlayer基本一致,只是升级了一下内部的一些代码。

安装

pnpm add contentlayer2 next-contentlayer2

之后按照contentlayer文档进行配置,主要是在项目根目录下新建contentlayer.config.ts文件;修改next.config.js文件,添加withContentlayer;修改tsconfig.json等。

配置contentlayer

contentlayer首先要配置数据源,在contentlayer.config.ts中导出数据源,将内容的文件夹路径设置为src/content

export default makeSource({
  contentDirPath: 'src/content',
  documentTypes: [],
})

之后配置Blog的数据类型,主要是设置属于Blog的mdx文件路径、内容类型、字段、计算字段等。

其中fields是mdx中开头的元数据,例如下方的title、tags、date等。Blog需要展示的标题、日期、标签、摘要等都可以在fields中设置

---
title: 这个博客网站是如何搭建的
tags: [react, nextjs, tailwindcss]
date: 2024-08-31
---

computedFields是一些计算字段,例如下方的slug,用于之后通过路由匹配文章(如果定义了path,就用path当做路由,否则用文件的文件路径当做路由)、readingTime 用于展示阅读时间等。

最终配置如下:

export const blogSource = defineDocumentType(() => ({
  name: 'Blog',
  filePathPattern: 'blog/**/*.(md|mdx)',
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date' },
    tags: { type: 'list', of: { type: 'string' } },
    summary: { type: 'string' },
    path: { type: 'string' },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: doc => doc.path ?? doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
    },
    readingTime: { type: 'json', resolve: doc => formatDuration(readingTime(doc.body.raw).time) },
  },
}))

除了Blog,在一些其他页面也需要展示MDX内容,例如about, home页面,所以可以配置一个page的文档类型。

可以直接通过key来找到对应的mdx文件,从而展示不同的内容。

export const pageSource = defineDocumentType(() => ({
  name: 'Page',
  filePathPattern: 'page/**/*.(md|mdx)',
  contentType: 'mdx',
  fields: {
    key: { type: 'string', required: true },
  },
}))

之后将这两个文档类型添加到makeSource的documentTypes字段中,就定义好需要的文档类型了。

export default makeSource({
  contentDirPath: 'src/content',
  documentTypes: [blogSource, pageSource],
})

之后就可以在src/content/blog或者src/content/page中添加mdx文件,contentlayer会自动解析这些文件,生成对应的数据。

添加mdx插件

remark

  • remark-gfm: 拓展markdown的功能,支持表格、任务列表等
  • remark-math: 支持数学公式
  • remark-img-to-jsx: 将本地文件使用Nextjs的Image组件进行替换
  • remark-github-blockquote-alert: 支持在markdown中使用github风格的alert

rehype

  • rehype-katex: 使用katex渲染数学公式
  • rehype-slug: 为heading自动生成id
  • rehype-autolink-headings: 为heading自动生成锚点
  • rehype-pretty-code: 代码块美化
  • rehype-preset-minify: 压缩html

最终代码如下:

mdx: {
    cwd: process.cwd(),
    remarkPlugins: [remarkMath, remarkGfm, remarkImgToJsx, remarkAlert],
    rehypePlugins: [
      rehypeKatex,
      rehypeSlug,
      [
        rehypeAutolinkHeadings,
        {
          behavior: 'prepend',
          headingProperties: {
            className: ['content-header'],
          },
          properties: {
            ariaHidden: undefined,
          },
          content: linkIcon.children, // Icon component
        },
      ],
      [
        rehypePrettyCode,
        {
          theme: 'one-dark-pro',
        },
      ],
      rehypePresetMinify,
    ],
  },

其他数据的处理

在生成数据之后,我们需要把所有Blog的tag提取出来存储在JSON文件中,用于展示tag列表。

export default makeSource({
	// {...}
  onSuccess: async importData => {
    const { allBlogs } = await importData()
    await generateTags(allBlogs)
  },
})

TODO: 生成文章内容的索引以供搜索使用

完整的配置可以查看contentlayer.config.ts

之后我们就可以在项目的页面中通过引入allBlogs或者allPages使用处理后的mdx数据了。

Blog列表

新建src/app/blog/page.tsx

import { allBlogs } from 'contentlayer/generated'

allBlogs是blog目录下所有文章的数据,将他们通过时间排序,然后展示在页面上

并且加入分页的功能,其中分页的功能是通过路径上的Query Params来获取当前页数,然后根据页数来展示不同的内容。

Blog详情页

新建src/app/blog/[...slug].tsx

通过slug来匹配对应的文章内容,然后展示在页面上。同时使用generateStaticParams来生成静态路由,提高访问速度。使用generateMetadata来生成meta信息,方便SEO。

使用useMDXComponent来生成MDX组件,然后展示在页面上。

const MDXContent = useMDXComponent(blog.body.code)
 
<MDXContent components={MDXComponents} />

其中MDXComponents是一些自定义的组件,例如ah1h2等,用于替换默认的组件。

import { cn } from '@/lib/utils'
 
import { ImagePreview } from '../image-preview'
 
import CustomLink from './custom-link'
import Image from './image'
 
import type { MDXComponents } from 'mdx/types'
 
const MDXComponents: MDXComponents = {
  h1: props => <h1 {...props} className={cn('mb-4 mt-6 text-4xl font-bold', props.className)} />,
  h2: props => (
    <h2
      {...props}
      className={cn('mb-4 mt-6 border-gray-200 pb-2 text-3xl font-semibold', props.className)}
    />
  ),
  h3: props => (
    <h3 {...props} className={cn('mb-4 mt-6 text-2xl font-semibold', props.className)} />
  ),
  h4: props => <h4 {...props} className={cn('mb-4 mt-6 text-xl font-semibold', props.className)} />,
  h5: props => <h5 {...props} className={cn('mb-4 mt-6 text-lg font-semibold', props.className)} />,
  h6: props => (
    <h6 {...props} className={cn('mb-4 mt-6 text-base font-semibold', props.className)} />
  ),
  p: props => <p {...props} className={cn('mb-4 mt-0', props.className)} />,
  a: props => (
    <CustomLink
      {...props}
      href={props.href ?? ''}
      className={cn('text-blue-600 underline', props.className)}
    />
  ),
  ul: props => <ul {...props} className={cn('mb-4 mt-0 list-disc pl-5', props.className)} />,
  ol: props => <ol {...props} className={cn('mb-4 mt-0 list-decimal pl-5', props.className)} />,
  li: props => <li {...props} className={cn('mb-2', props.className)} />,
  code: props => (
    <code {...props} className={cn('rounded bg-gray-600 px-1 text-white', props.className)} />
  ),
  pre: props => (
    <pre {...props} className={cn('overflow-x-auto rounded bg-gray-600 p-4', props.className)} />
  ),
  blockquote: props => (
    <blockquote
      {...props}
      className={cn(
        'my-4 border-l-4 border-gray-200 pl-4 italic text-gray-400 dark:text-gray-300',
        props.className
      )}
      {...props}
    />
  ),
  img: props => (
    <ImagePreview src={props.src}>
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img alt="" {...props} className={cn('mb-4 cursor-pointer', props.className)} />
    </ImagePreview>
  ),
  Image,
}
 
export default MDXComponents

之后再需要再加上一些样式,例如代码块的样式、嵌套列表的样式等: mdx.css

毕竟是博客网站,字体的美化也同样重要,这里使用的是程普大佬的weekly-boilerplate项目中使用的字体霞鹜

还需要额外加一些内容:

  • 上一篇文章和上一篇文章的链接,方便用户查看其他文章。
  • 评论组件,方便用户进行评论,这里采用的是giscus,可以使用github的Discussions实现评论的功能。
  • TOC: 文章的目录,方便用户查看文章的结构。
  • 返回顶部按钮,方便用户回到页面顶部。

Tag列表

新建src/app/tag/page.tsxsrc/app/tag/[...tag].tsx,分别用于展示所有tag和单个tag下的文章。

因为上文就已经生成了tag的数据,所以直接使用这个数据渲染即可。

Home页面

新建一篇src/content/page/home.mdx,用于展示首页的内容。

在文章中设置key字段,用于在页面中找到对应的文章内容。

---
key: home
---

之后在src/app/page.tsx中引入这篇文章,然后展示在页面上。

import { allPages } from 'contentlayer/generated'
 
const home = allPages.find(page => page.key === 'home')

同理,还可以添加about等mdx,并在对应页面展示。

sitemap

sitemap是一个用于搜索引擎的网站地图,可以让搜索引擎更好的爬取网站的内容。

我们使用next-sitemap来生成sitemap,首先安装依赖:

pnpm add next-sitemap

之后新建文件next-sitemap.config.js,配置sitemap的一些信息。

/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://me.tuntun.site',
  generateRobotsTxt: true,
  sitemapSize: 7000,
}

之后在package.json中添加命令:

"scripts": {
  // {...}
  "postbuild": "next-sitemap"
},

之后每次构建完成后,就会自动生成sitemap.xml文件和robots文件了。

总结

通过以上的步骤,我们就可以搭建一个基础的博客网站了。

其中一些其他类型网站都通用的细节没有写,比如404页面、loading页面、错误页面以及主题切换功能等等,这些都可以根据自己的需求进行添加。

是不是很简单呢?