跳到主要内容

支持 MDX 文件渲染

如今大多静态文档网站生成技术都支持 mdx 格式文件的渲染。这得益于像 contentlayer 这样的库的支持,将非结构化内容转换为类型安全的 json 数据结构。

静态站点生成技术:built static site

这里要介绍的一种解决方案是借助 contentlayer 这个工具库。

安装

安装 contentlayer

在 Next 项目中,你需要额外安装 next-contentlayer,它提供了对 contentlayer 接口的封装以便对 Nextjs 框架的支持。

npm install contentlayer next-contentlayer --save

使用 withContentLayer 函数对 Next 配置进行包装

next.config.js 文件中导入 withContentLayer 函数并使用它对 Next 配置进行包装。

import { withContentLayer } from "next-contentlayer";

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

module.exports = withContentlayer(nextConfig);

添加编译选项以及生成路径

tsconfig.json 文件中添加以下配置,以满足对生成目录的访问。

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

定义结构

首先在项目根目录下创建了一个 /content 文件夹。然后在 /content 文件夹下,创建了两个文件夹 /definitions/posts

  • definitions:放置对数据结构的定义文件
  • posts:放置以 mdx 格式撰写的文章
content
├── definitions
└── posts

这里以 Post 为例,我们在 /definitions 目录下新建 post.tsx,用它来定义单篇文章的内容结构:

import { defineDocumentType } from "contentlayer/source-files";

export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: "posts/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
publishedAt: { type: "string", required: true },
description: { type: "string" },
status: {
type: "enum",
options: ["draft", "published"],
required: true,
},
},
}));

还有另外两个模型需要定义:TagSeriesTag 将用于给文章定义标签,而 Series 定义与某篇文章相关的文章,用于推荐。

./content/definitions/tag.tsx
import { defineNestedType } from "contentlayer/source-files";

// define tags elsewhere (in a constants file)
import { tagNames, tagSlugs } from "../../lib/contentlayer";

export const Tag = defineNestedType(() => ({
name: "Tag",
fields: {
title: {
type: "enum",
required: true,
options: tagNames,
},
slug: {
type: "enum",
required: true,
options: tagSlugs,
},
},
}));
./content/definitions/series.tsx
import { defineNestedType } from "contentlayer/source-files";

export const Series = defineNestedType(() => ({
name: "Series",
fields: {
title: {
type: "string",
required: true,
},
order: {
type: "number",
required: true,
},
},
}));

定义好 SeriesTag 之后,就可以把它们导入到 Post 中使用它们来完成字段的定义。

./content/definitions/post.tsx
import { defineDocumentType } from "contentlayer/source-files";

import { Tag } from "./tag";
import { Series } from "./series";

export const Post = defineDocumentType(() => ({
// ...
fields: {
title: { type: "string", required: true },
publishedAt: { type: "string", required: true },
description: { type: "string" },
status: {
type: "enum",
options: ["draft", "published"],
required: true,
},
series: {
type: "nested",
required: false,
of: Series,
},
tags: {
type: "list",
required: false,
of: Tag,
},
},
}));

配置文件

现在,我们需要将定义好的模型提供给 Contentlayer ,在项目根目录下新建 contentlayer.config.js 文件。

contentlayer.config.js
import { makeSource } from "contentlayer/source-files";

import { Post } from "./content/defintions/post";

export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
mdx: {
esbuildOptions(options) {
options.target = "esnext";
return options;
},
remarkPlugins: [],
rehypePlugins: [],
},
});

插件

请注意上面 contentlayer.config.js 文件中的 remarkPluginsrehypePlugins 两个字段,Contentlayer 非常强大,我们可以在内容生成的过程中 使用各种插件。

Github Flavored Markdown

Github Flavored Markdown 是 Github 在处理 Md 文件时使用的工具其中之一。我们可以使用 remark-gfm 来启用它。

npm install remark-gfm

然后把它引入 contentlayer.config.js 配置文件中

import { makeSource } from "contentlayer/source-files";
import { Post } from "./content/defintions/post";
import remarkGfm from "remark-gfm";

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

处理标题文章链接

给标题文本添加上超链接便签,便于点击跳转

npm install rehype-autolink-headings github-slugger rehype-slug
import { makeSource } from "contentlayer/source-files";
import { Post } from "./content/defintions/post";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-heading";

export default makeSource({
// ...
mdx: {
// ...
remarkPlugins: [[remarkGfm]],
rehypePlugins: [
[rehypeSlug],
[
rehypeAutolinkHeadings,
{
behavior: "wrap",
properties: {
className: ["<insert class names here>"],
},
},
],
],
},
});

最后你需要到 Post 模型中,从内容中获取文章的链接

./content/definitions/post.tsx
import { defineDocumentType } from "contentlayer/source-files";
import { Tag } from "./tag";
import { Series } from "./series";
import GithubSlugger from "github-slugger";

export const Post = defineDocumentType(() => ({
// ...
computedFields: {
headings: {
type: "json",
resolve: async (doc) => {
const slugger = new GithubSlugger();

// https://stackoverflow.com/a/70802303
const regex = /\n\n(?<flag>#{1,6})\s+(?<content>.+)/g;

const headings = Array.from(doc.body.raw.matchAll(regex)).map(
// @ts-ignore
({ groups }) => {
const flag = groups?.flag;
const content = groups?.content;
return {
heading: flag?.length,
text: content,
slug: content ? slugger.slug(content) : undefined,
};
}
);

return headings;
},
},
},
}));

Slug

为了在 Next.js 中给每篇文章渲染一个页面,需要生成一个对应的 slug,这时在 Post 模型中添加另一个 computedField 字段。

./content/definitions/post.tsx
import { defineDocumentType } from "contentlayer/source-files";
import { Tag } from "./tag";
import { Series } from "./series";
import GithubSlugger from "github-slugger";

export const Post = defineDocumentType(() => ({
// ...
computedFields: {
// ...
slug: {
type: "string",
resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx$/, ""),
},
},
}));

渲染内容

我们可以使用 Next.js 中的动态路由来渲染文章页面,在 /pages 目录下,我们创建 post 目录和 [slug].tsx 文件。用大括号包起来表示它是一个参数。

然后我们可以直接从 contentlayer/generated 中获取由 contentlayer 生成的一个 json 格式的包含所有文章的一个数组。

import { allPosts } from "contentlayer/generated";

通过这个导入,您可以获取所有生成的文章,并根据需要进行渲染。这里为了便于直接从页面中获取,把它放在页面的 getStaticProps 方法中。

import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";

export async function getStaticProps() {
const posts = allPosts
.sort((a, b) => {
return compareDesc(new Date(a.publishedAt), new Date(b.publishedAt));
})
.filter((p) => p.status === "published");

return { props: { posts: posts } };
}