跳到主要内容

52-实战篇 博客 Contentlayer

前言

本篇开始,我们使用 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