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

TOC

简介

在本文中,我们将额外介绍一种新工具contentlayer,在Next.js(v13+)搭建MDX博客应用的方法,它因具有重量轻,易于使用、 出色的开发体验以及快速的构建能力和高性能页面的优点的,以及从源文件加载内容,并自动生成 TypeScript 类型定义,确保正在处理的内容符合您期望的形状,而深受广大用户喜爱。

Contentlayer 是一个内容 SDK,可验证您的内容并将其转换为类型安全的 JSON 数据,您可以轻松地import将其添加到应用程序的页面中。

Important

⚠️ Contentlayer 目前处于测试阶段。在即将发布的 1.0 版本之前,可能仍会发生重大更改。

Caution

⚠️ 同时由于某些原因该项目已长时间未维护,请慎重考虑后再使用 详情

next-mdx-contentlayer

准备

如上篇文章Next.js(v13+) 创建 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是为了方便后续页面排版,当然也可以根据您的喜好不选择。

开始

安装 Contentlayer 和 Next.js 插件

pnpm add contentlayer next-contentlayer

使用withContentlayer方法包裹Next.js配置,以便将ContentLayer钩子挂接到next devnext build过程中。

// next.config.js
const { withContentlayer } = require('next-contentlayer')

/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true }

module.exports = withContentlayer(nextConfig)

然后,添加下面行中的代码到tsconfig.jsonjsconfig.json文件中。

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    //  ^^^^^^^^^^^
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // ^^^^^^^^^^^^^^^^^^^^^^
  ]
}

这些配置将使告诉Next.js构建过程和你的编辑器在哪里寻找生成的文件,并让它们在你的代码更容易导入。

忽略构建输出

.contentlayer目录添加到你的.gitignore文件中,以确保你的应用程序的每个构建都有最新生成的数据,并且你不会遇到Git问题。

# .gitignore

# ...

# contentlayer
.contentlayer

添加配置

在项目的根部创建文件contentlayer.config.ts,然后添加以下内容。

// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'

// 文档类型
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.md`,
  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] })

该配置指定了一个名为Post的文档类型。这些文档是位于项目中的posts目录中的Markdown文件。

从这些文件生成的任何数据对象都将包含上面指定的字段,以及包含文件的原始内容和HTML内容的正文字段。url字段是一个特殊的计算字段,它会根据源文件中的元属性自动添加到所有发布文档中。

添加文章内容

/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

添加网站代码

创建/app/posts/page.tsx用于展示所有Post文章列表。请注意,在尝试从contentlayer/regenerated导入时会出现错误,这是正常的,稍后将通过运行开发服务器来修复它。

// app/page.tsx
import Link from "next/link";
import { compareDesc, format, parseISO } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";

function PostCard(post: 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">
        {format(parseISO(post.date), "LLLL d, yyyy")}
      </time>
      <div
        className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0"
        dangerouslySetInnerHTML={{ __html: post.body.html }}
      />
    </div>
  );
}

export default function Home() {
  const posts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date))
  );

  return (
    <div className="mx-auto max-w-xl py-8">
      {posts.map((post, idx) => (<PostCard key={idx} {...post} />))}
    </div>
  );
}

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

pnpm dev

添加Post布局

现在创建app/posts/[slug]/page.tsx页面,并添加以下代码

// app/posts/[slug]/page.tsx
import { format, parseISO } from 'date-fns'
import { allPosts } from 'contentlayer/generated'

export const generateStaticParams = async () => allPosts.map((post) => ({ slug: post._raw.flattenedPath }))

export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`)
  return { title: post.title }
}

const PostLayout = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`)

  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">
          {format(parseISO(post.date), 'LLLL d, yyyy')}
        </time>
        <h1 className="text-3xl font-bold">{post.title}</h1>
      </div>
      <div className="[&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />
    </article>
  )
}

export default PostLayout;

现在,点击文章列表上的链接,将进入一文章阅读页面。

开启MDX

Contentlayer中使用MDX只需在配置文件contentlayer.config.ts中添加如下代码即可

...
export const Post = defineDocumentType(() => ({
...
+ contentType: 'mdx',
...
}));
...

创建一个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 { useMDXComponent } from "next-contentlayer/hooks";
+ import Button from "./mdx/Button";
...
const PostLayout = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`);

+  const MDXContent = useMDXComponent(post.body.code);

  return (
    <article className="mx-auto max-w-xl py-8">
 ...
-     <div className="[&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />

+     <div className="[&>*]:mb-3 [&>*:last-child]:mb-0">
+       <MDXContent components={{ Button }} />
+     </div>
    </article>
  );
};

...

最后删除/app/posts/page.tsx文件中如下代码

- <div
-   className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0"
-   dangerouslySetInnerHTML={{ __html: post.body.html }}
-  />

此时,带交互功能的文件文章配置就完成啦。

扩展

这里仅介绍关于contentlayer的一些特殊的扩展配置,其它的参考Next.js(v13+) 创建 MDX 博客文章中的扩展,其原理一样,这里不再赘述。异常处理解决方式也同样。

MDX插件配置

可以通过remarkrehype来转换 MDX 内容。例如,使用remark-gfm来实现 GitHub Flavored Markdown 来支持。

// contentlayer.config.ts
import { makeSource } from '@contentlayer/source-files'
import highlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'

export default makeSource({
  // ...
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [highlight],
  },
})

阅读时间

通过reading-time可以为我们的文章添加阅读时间、文章字数元数据。

在配置文件contentlayer.config.ts中添加以下代码可为contentlayer添加文章阅读时长

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

// 文档类型
export const Post = defineDocumentType(() => ({
...
  computedFields: {
    ...
+   readingTime: { type: "json", resolve: (doc) => readingTime(doc.body.raw) },
  },
}));
...

相关链接

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