JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第52章—实战篇-博客-Contentlayer

首次发表于 2024-07-29, 更新于 2024-07-29

前言

本篇开始,我们使用 Next.js 官方脚手架从零实现一个博客项目。那就让我们直接开始吧!

初始化项目

运行 npx create-next-app@latest新建项目,效果如下:

image.png

运行以下命令安装依赖项并开启开发模式:

cd next-blog && npm i && npm run dev

打开 http://localhost:3000/,检查页面是否正常运行:

image.png

如何处理 MDX

博客的一大组成部分是文章,技术同学写文章大多使用 Markdown。哪怕像我写在语雀,也会导出成 Markdown 格式,然后发在掘金……

本地 mdx

我们在 《配置篇 | MDX》讲了如何借助 @next/mdx 处理 Markdown 的超集 MDX。当配置完毕后,将原本的 page.js 替换为 page.mdx:

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

这样当你访问 /my-mdx-page路由的时候,就会打开渲染后的 mdx 内容。

但是这样做的问题在于:如果我要上传一篇文章,我还需要手动新建一个文件夹用于它的路由地址,这属实有点麻烦。

远程 mdx

为了简化这个步骤,我们通常会新建一个存放所有文章的文件夹,然后使用动态路由,动态读取对应的文章。

我们试着写一下。先安装一个处理 MDX 的库:

npm i next-mdx-remote

涉及的文件和目录如下:

next-blog              
├─ app                 
│  ├─ posts            
│  │  └─ [id]          
│  │     └─ page.js         
└─ posts               
   └─ first.mdx        

新建 app/posts/[id]/page.js,代码如下:

import { compileMDX } from 'next-mdx-remote/rsc'
import { readFile } from 'node:fs/promises';
import path from 'path';

async function getMDXContent(name) {
  try {
    const filePath = path.join(process.cwd(), '/posts/', `${name}.mdx`)
    const contents = await readFile(filePath, { encoding: 'utf8' });
    return await compileMDX({ source: contents, options: { parseFrontmatter: true }})
  } catch (err) {
    return null
  }
}

export async function generateMetadata({ params, searchParams }, parent) {
  const res = await getMDXContent(params.id);
  if (!res) return { title: ''}
  const { frontmatter } = res;
  return { title: frontmatter.title }
}

export default async function Home({ params }) {
  const res = await getMDXContent(params.id);
  if (!res) return <h1>Page not Found!</h1>
  const {content, frontmatter} = res;

  return (
    <>
      {content}
    </>
  )
}

新建 /posts/first.mdx,代码如下:

---
title: Hello World Article
---

# Hello World!

this is content

此时打开 http://localhost:3000/posts/first,效果如下:

image.png

可以看到:MDX 内容成功渲染,且使用 Frontmatter 实现了页面的元数据设置。

但是这样做还是有些问题:

  1. 没有构建优化。页面请求的时候才读取对应的 MDX 内容进行渲染,过程并没有做优化,比如提前进行编译
  2. 没有类型定义。比如 Frontmatter,代码中用的是 title,但在 MDX 中写作了 tilte,但并不会出现构建错误或提示(相信这种拼写错误大家一般不会犯,更多出现的是 tags 和 tag 这种)
  3. 没有实时刷新。比如修改 first.mdx,页面内容并不会自动刷新
  4. 内容没有被缓存。每次都是重新读取页面内容并渲染。

Contentlayer

这就是为什么我们需要 Contentlayer。

Contentlayer,顾名思义,内容层。它会将内容转为数据,这样我们就可以在任意组件导入内容,就像我们导入其他库一样。

“将内容转为数据”听起来有些抽象,其实很简单,其本质是监听文件改变,将原本的 md、mdx 等文档内容转为 js、json 等格式,其中包含文档的各种信息,就比如将这样一个名为 first.mdx 的文档:

---
title: Hello World Article
date: 2014-05-01
---

# Hello, World!

转为这样一个 js 文件:

{
    title: 'Hello World Article',
    date: '2014-05-01T00:00:00.000Z',
    body: {
      raw: "...",
      code: "var Component=(()=>{var m=Object.create ..."
    },
    _id: 'first.mdx',
    _raw: {
      sourceFilePath: 'first.mdx',
      sourceFileName: 'first.mdx',
      sourceFileDir: '.',
      contentType: 'mdx',
      flattenedPath: 'first'
    }
  }

当在组件中使用的时候,不需要再读取原本的 mdx 文件内容,而是导入这个编译后的 js 文件即可。

可能听起来还是有些抽象,还是让我们在实战中体会它的作用吧。

安装设置

尝试安装 next-contentlayer:

npm i next-contentlayer

如果出现版本不兼容错误:

image.png

修改 package.json,添加以下代码再进行安装:

{
  // ...
  "overrides": {
    "next-contentlayer": {
      "next": "$next"
    }
  }
}

顺便再安装一些后续会用到的库:

npm i dayjs rehype-prism-plus remark-gfm@3.0.1

其中:

  1. dayjs 用于处理时间展示
  2. rehype-prism-plus 用于处理语法高亮
  3. remark-gfm 用于扩展 Markdown 语法

修改 next.config.mjs,完整代码如下:

import { withContentlayer } from 'next-contentlayer'
export default withContentlayer({})

修改 jsconfig.json,完整代码如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"],
      "@/*": ["./*"]
    }
  },
  "include": [
    "next-env.d.js",
    "**/*.js",
    "**/*.jsx",
    ".next/types/**/*.js",
    ".contentlayer/generated"
  ]
}

修改 .gitignore 文件,添加如下代码:

# contentlayer
.contentlayer

.contentlayer 存放的正是 md、mdx 编译后的文件,这些并不需要提交到远程仓库。

定义内容 Schema

根目录新建 contentlayer.config.ts,代码如下:

import { defineDocumentType, makeSource } from 'contentlayer/source-files'
import remarkGfm from 'remark-gfm'
import rehypePrismPlus from 'rehype-prism-plus'

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
  },
  computedFields: {
    url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
  },
}))

export default makeSource({ 
  contentDirPath: 'posts', 
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],],
  }
})

在这段代码中,makeSource 定义了 markdown 文档所在的位置和用到的插件,defineDocumentType 定义了 Frontmatter 的字段类型,比如我们的文档需要定义 title 和 date 两个字段,两个字段都是必须的,如果缺失某些字段,会有错误提示:

image.png

如果使用了未定义的字段,也会出现错误提示:

image.png

添加站点代码

修改 /posts/first.mdx,代码如下:

---
title: Hello World Article
date: 2014-05-01
---

# Hello, World!

**这是一段加粗文字**

~~这是一段删除文字~~

```js {1,3-4} showLineNumbers
function fancyAlert(arg) {
  if (arg) {
    $.facebox({ div: '#foo' })
  }
}

新建 `/app/posts/page.js`,代码如下:

```jsx
import Link from 'next/link'
import { allPosts } from 'contentlayer/generated'
import dayjs from "dayjs";

function PostCard(post) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link href={post.url} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {dayjs(post.date).format('DD/MM/YYYY')}
      </time>
    </div>
  )
}

export default function Home() {
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">My Blog List</h1>
      {allPosts.map((post, idx) => (
      <PostCard key={idx} {...post} />
    ))}
    </div>
  )
}

在这段代码中,我们从 'contentlayer/generated'中导出了 allPosts 变量,这有点让人奇怪,allPosts 到底是哪里定义的呢?

前面我们说过 contentlayer 的本质是实时编译,将 md 文档编译成普通的 js 文件,编译后的内容存放在项目根目录下的 .contentlayer文件夹中。

我们在 contentlayer.config.ts 中定义了一个名为 Post 的文档类型,对应的所有数据就是 all + 它的复数形式,也就是 allPosts。

再举个例子,如果定义的文档类型名称为 Page,对应的所有文档数据则为 allPages,它本质上一个包含所有导入 JSON 文档的数组。让我们打印下 allPosts 看一下具体的结构:

[
  {
    title: 'Hello World Article',
    date: '2014-05-01T00:00:00.000Z',
    body: {
      raw: "...",
      code: "var Component=(()=>{var m=Object.create ..."
    },
    _id: 'first.mdx',
    _raw: {
      sourceFilePath: 'first.mdx',
      sourceFileName: 'first.mdx',
      sourceFileDir: '.',
      contentType: 'mdx',
      flattenedPath: 'first'
    },
    type: 'Post',
    url: '/posts/first'
  },
  {
    title: 'Hello Earth Article',
    date: '2014-05-02T00:00:00.000Z',
    body: {
      raw: "...",
      code: "..."
    },
    _id: 'second.mdx',
    _raw: {
      sourceFilePath: 'second.mdx',
      sourceFileName: 'second.mdx',
      sourceFileDir: '.',
      contentType: 'mdx',
      flattenedPath: 'second'
    },
    type: 'Post',
    url: '/posts/second'
  }
]

allPosts 是一个数组,每一个元素包含了该文档的所有 FontMatter 字段以及文档的原内容(body.raw)和编译后的内容(body.code)。

此时浏览器效果如下:

image.png

修改 app/posts/[id]/page.js,代码如下:

import { allPosts } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
import { notFound } from 'next/navigation'
import dayjs from "dayjs";

export async function generateStaticParams() {
  return allPosts.map((post) => ({
    id: post._raw.flattenedPath,
  }))
}
export const generateMetadata = ({ params }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.id)
  if (!post) throw new Error(`Post not found for id: ${params.id}`)
  return { title: post.title }
}

const Page = ({ params }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.id)
  if (!post) notFound()
  const MDXContent = useMDXComponent(post.body.code)

  return (
    <article className="mx-auto max-w-xl py-8">
      <div className="mb-8 text-center">
        <time dateTime={post.date} className="mb-1 text-xs text-gray-600">
          {dayjs(post.date).format('DD/MM/YYYY')}
        </time>
        <h1 className="text-3xl font-bold">{post.title}</h1>
      </div>
      <MDXContent />
    </article>
  )
}

export default Page

因为我们使用了 rehypePrismPlus 作为代码的样式插件,它会将代码编译成带类名的 html:

截屏2024-05-06 17.42.36.png

但因为我们的代码并没有定义这些类名的样式,所以我们还需要添加下样式。

修改 app/global.css,添加代码如下:

pre {
  overflow-x: auto;
}

/**
 * Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/
 * 1. Make the element just wide enough to fit its content.
 * 2. Always fill the visible space in .code-highlight.
 */
.code-highlight {
  float: left; /* 1 */
  min-width: 100%; /* 2 */
}

.code-line {
  display: block;
  padding-left: 16px;
  padding-right: 16px;
  margin-left: -16px;
  margin-right: -16px;
  border-left: 4px solid rgba(0, 0, 0, 0); /* Set placeholder for highlight accent border color to transparent */
}

.code-line.inserted {
  background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */
}

.code-line.deleted {
  background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */
}

.highlight-line {
  margin-left: -16px;
  margin-right: -16px;
  background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */
  border-left: 4px solid rgb(59, 130, 246); /* Set highlight accent border color */
}

.line-number::before {
  display: inline-block;
  width: 1rem;
  text-align: right;
  margin-right: 16px;
  margin-left: -8px;
  color: rgb(156, 163, 175); /* Line number color */
  content: attr(line);
}

这些样式是为了代码块显示行号等信息。

至于代码的样式,到 Prism themes 选择一个你喜欢的样式,然后拷贝其 CSS 文件。比如我选择的是普通的 VSCode Dark 样式,地址为:https://github.com/PrismJS/prism-themes/blob/master/themes/prism-vsc-dark-plus.css

将这段代码也拷贝到 app/global.css中,最后的效果如下:

image.png

Tailwind CSS

让我们真的写一篇文章试试,实际渲染后的效果为:

image.png

虽然对应的 HTML 标签渲染都是正确的,但因为 Tailwind CSS 默认会将所有元素的样式重置,所以最后的效果并不算“好看”。

不过 Tailwind.css 官方提供了 Tailwind CSS Typography 插件用于设置样式的默认值。安装:

npm install -D @tailwindcss/typography @tailwindcss/forms

修改 tailwind.config.js,完整代码如下:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    './data/**/*.mdx',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      lineHeight: {
        11: '2.75rem',
        12: '3rem',
        13: '3.25rem',
        14: '3.5rem',
      },
      typography: ({ theme }) => ({
        DEFAULT: {
          css: {
            a: {
              color: theme('colors.primary.500'),
              '&:hover': {
                color: `${theme('colors.primary.600')}`,
              },
              code: { color: theme('colors.primary.400') },
            },
            'h1,h2': {
              fontWeight: '700',
              letterSpacing: theme('letterSpacing.tight'),
            },
            h3: {
              fontWeight: '600',
            },
            code: {
              color: theme('colors.indigo.500'),
            },
          },
        },
        invert: {
          css: {
            a: {
              color: theme('colors.primary.500'),
              '&:hover': {
                color: `${theme('colors.primary.400')}`,
              },
              code: { color: theme('colors.primary.400') },
            },
            'h1,h2,h3,h4,h5,h6': {
              color: theme('colors.gray.100'),
            },
          },
        },
      }),
    },
  },
  plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
};

修改 app/posts/[id]/page.js,代码如下:

// ...

const Page = ({ params }) => {
  // ...

  return (
    <article className="mx-auto max-w-xl py-8 prose prose-slate">
      // ...
    </article>
  )
}

export default Page

Tailwind CSS Typography 通过在外层添加一个 prose 和 prose-xxx 类来控制其中元素的样式,有五种预定义的颜色和比例选项可用(这里我们用的是 prose-slate),此外还支持深色模式,具体参考其官方说明

最后的效果如下:

image.png

是不是看起来就正常多了?

  1. 功能实现:博客 Contentlayer
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-1
  3. 下载代码:git clone -b next-blog-1 git@github.com:mqyqingfeng/next-app-demo.git

总结

本篇我们介绍了 Contentlayer 的出现背景和使用方法,它是处理 MD 和 MDX 等内容的利器,但是 Contentlayer 这一两年近乎没有更新,使用的时候可能会遇到一些版本问题,不过目前尚未看到更好的替代方案。

参考链接

  1. https://www.youtube.com/watch?v=58Pj4a4Us7A&ab_channel=Contentlayer
  2. https://contentlayer.dev/docs/getting-started-cddd76b7
  3. https://github.com/tailwindlabs/tailwindcss-typography
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru