让contentlayer帮你把md文件变成静态页面吧
Contentlayer 是什么
Contentlayer 是一个功能强大的静态网站生成器,专为构建和管理静态页面、网站和博客而设计。它提供了一种简单而灵活的方式来创建和组织内容,可以将Markdown
文件转换为静态HTML
页面。
Contentlayer 有什么用
- **内容建模:**Contentlayer 有一个配置文件,你可以定义不同类型的内容,如文章、页面、作者等,并为每个类型定义字段和关联关系。
- **Markdown支持:**Contentlayer 使用
Markdown
文件作为内容源,它可以把你写的MDX
文件解析为HTML
,并生成静态页面或嵌入到其他静态页面中。 - **静态网站生成:**如上所述,你也可以利用
Markdown
支持轻松生成和更新你的静态网站。 - **自定义渲染:**Contentlayer 允许你自定义
React
组件来替换或扩展默认的Markdown渲染器,以实现更多你想要的样式。
使用步骤
Contentlayer 使用方法乍一看容易一头雾水,用完之后又容易忘记一些细节,所以需要记录下使用步骤。
以下示例基于NextJS 13.x
版本的app router
实现。
- 安装依赖
pnpm i @types/mdx concurrently contentlayer next-contentlayer -S
再安装几个markdown插件,这些插件可以增强markdown渲染样式,并非每个都需要,挑自己需要的安装,也可以全部安装慢慢享用
pnpm i rehype-autolink-headings rehype-pretty-code rehype-raw rehype-slug rehype-stringify remark-gfm remark-math remark-rehype -S
如果你想问这些rehype
和remark
插件都是什么作用,请猛击👉一些好用的Markdown优化插件
-
创建Contentlayer配置文件,定义内容模型和数据源
在你的Next.js项目根目录下创建一个名为
contentlayer.config.js
的文件,并开始定义内容模型和配置选项。在配置文件中,你可以定义不同类型的内容,如文章、页面等,并为每个类型定义字段和关联关系。上代码和注释:
import { defineDocumentType, makeSource } from "contentlayer/source-files"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypePrettyCode from "rehype-pretty-code"
import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm"
/** @type {import('contentlayer/source-files').ComputedFields} */
const computedFields = { // 定义计算字
slug: { // 计算字段用于生成文档的URL slug
type: "string",
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
slugAsParams: { // 计算字段用于生成文档的URL参数形式的slug
type: "string",
resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"),
},
}
// defineDocumentType 定义文档类型。可以这个参考格式定义多种文档。
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `**/*.mdx`, // 指定匹配的文件路径模式
contentType: "mdx", // 指定了文档类型为 mdx
fields: { // 定义文档的字段结构,因为我接下来的示例中只用到title和description字段,所以其他字段被我注释掉了。如果此处配置的字段和实际mdx文件中用到的不一样,编译会报错
title: {
type: "string",
required: true,
},
description: {
type: "string",
},
// date: {
// type: "date",
// required: true,
// },
// published: {
// type: "boolean",
// default: true,
// },
// image: {
// type: "string",
// required: true,
// },
// authors: {
// // Reference types are not embedded.
// // Until this is fixed, we can use a simple list.
// // type: "reference",
// // of: Author,
// type: "list",
// of: { type: "string" },
// required: true,
// },
},
computedFields,
}))
// makeSource 创建数据源
export default makeSource({
contentDirPath: "./content", // 指定内容文件的目录路径
documentTypes: [Post], // 指定使用的文档类型,支持多个
mdx: { // 配置MDX解析器的插件
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypePrettyCode,
{
theme: "github-dark",
onVisitLine(node) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }]
}
},
onVisitHighlightedLine(node) {
node.properties.className.push("line--highlighted")
},
onVisitHighlightedWord(node) {
node.properties.className = ["word--highlighted"]
},
},
],
[
rehypeAutolinkHeadings,
{
properties: {
className: ["subheading-anchor"],
ariaLabel: "Link to section",
},
},
],
],
},
})
-
添加styles/mdx.css
这不是必须的,作用是定义样式规则,用于美化渲染后的代码块
[data-rehype-pretty-code-fragment] code {
@apply grid min-w-full break-words rounded-none border-0 bg-transparent p-0 text-sm text-black;
counter-reset: line;
box-decoration-break: clone;
}
[data-rehype-pretty-code-fragment] .line {
@apply px-4 py-1;
}
[data-rehype-pretty-code-fragment] [data-line-numbers] > .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1rem;
margin-right: 1rem;
text-align: right;
color: gray;
}
[data-rehype-pretty-code-fragment] .line--highlighted {
@apply bg-slate-300 bg-opacity-10;
}
[data-rehype-pretty-code-fragment] .line-highlighted span {
@apply relative;
}
[data-rehype-pretty-code-fragment] .word--highlighted {
@apply rounded-md bg-slate-300 bg-opacity-10 p-1;
}
[data-rehype-pretty-code-title] {
@apply mt-4 py-2 px-4 text-sm font-medium;
}
[data-rehype-pretty-code-title] + pre {
@apply mt-0;
}
-
编辑tsconfig.json
"paths"
字段用于配置模块解析的路径映射"include"
字段用于指定要包含在编译过程中的文件或目录在这两个字段里添加
contentlayer
的配置
{
……
"paths": {
……
"contentlayer/generated": ["./.contentlayer/generated"] // 添加本行,这是后面markdown文件编译后存放的目录
},
……
"include": [
……
".next/types/**/*.ts" // 添加本行
],
}
- 用
withContentLayer
更新next config配置
先把`next.config.js`改为`next.config.mjs`以支持`ES import`
import { withContentlayer } from "next-contentlayer"
/** @type {import('next').NextConfig} */
const nextConfig = {
……
}
export default withContentlayer(nextConfig)
-
配置基本完成了,现在开始写md渲染组件
创建
components/mdx
文件夹,在里面分别创建callout.tsx
mdx-card.tsx
mdx-components.tsx
三个文件。最后markdown
页面好不好看全靠这三个文件了
// callout.tsx
import { cn } from "@/lib/utils"
interface CalloutProps {
icon?: string
children?: React.ReactNode
type?: "default" | "warning" | "danger"
}
export function Callout({
children,
icon,
type = "default",
...props
}: CalloutProps) {
return (
<div
className={cn("my-6 flex items-start rounded-md border border-l-4 p-4", {
"border-red-900 bg-red-50": type === "danger",
"border-yellow-900 bg-yellow-50": type === "warning",
})}
{...props}
>
{icon && <span className="mr-4 text-2xl">{icon}</span>}
<div>{children}</div>
</div>
)
}
// mdx-card.tsx
import Link from "next/link"
import { cn } from "@/lib/utils"
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
href?: string
disabled?: boolean
}
export function MdxCard({
href,
className,
children,
disabled,
...props
}: CardProps) {
return (
<div
className={cn(
"group relative rounded-lg border p-6 shadow-md transition-shadow hover:shadow-lg",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
<div className="flex flex-col justify-between space-y-4">
<div className="space-y-2 [&>h3]:!mt-0 [&>h4]:!mt-0 [&>p]:text-muted-foreground">
{children}
</div>
</div>
{href && (
<Link href={disabled ? "#" : href} className="absolute inset-0">
<span className="sr-only">View</span>
</Link>
)}
</div>
)
}
// mdx-components.tsx
import * as React from "react";
import Image from "next/image";
import { useMDXComponent } from "next-contentlayer/hooks";
import type { MDXComponents } from "mdx/types";
import { cn } from "@/lib/utils";
import { Callout } from "@/components/mdx/callout";
import { MdxCard } from "@/components/mdx/mdx-card";
const components: MDXComponents = {
h1: ({ className, ...props }) => (
<h1
className={cn(
"mt-2 scroll-m-20 text-4xl font-bold tracking-tight",
className
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"mt-10 scroll-m-20 border-b pb-1 text-3xl font-semibold tracking-tight first:mt-0",
className
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"mt-8 scroll-m-20 text-2xl font-semibold tracking-tight",
className
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"mt-8 scroll-m-20 text-xl font-semibold tracking-tight",
className
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"mt-8 scroll-m-20 text-lg font-semibold tracking-tight",
className
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn(
"mt-8 scroll-m-20 text-base font-semibold tracking-tight",
className
)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn("font-medium underline underline-offset-4", className)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul className={cn("my-6 ml-6 list-disc", className)} {...props} />
),
ol: ({ className, ...props }) => (
<ol className={cn("my-6 ml-6 list-decimal", className)} {...props} />
),
li: ({ className, ...props }) => (
<li className={cn("mt-2", className)} {...props} />
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn(
"mt-6 border-l-2 pl-6 italic [&>*]:text-muted-foreground",
className
)}
{...props}
/>
),
img: ({
className,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={cn("rounded-md border", className)} alt={alt} {...props} />
),
hr: ({ ...props }) => <hr className="my-4 md:my-8" {...props} />,
table: ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-6 w-full overflow-y-auto">
<table className={cn("w-full", className)} {...props} />
</div>
),
tr: ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr
className={cn("m-0 border-t p-0 even:bg-muted", className)}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={cn(
"border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={cn(
"border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"mb-4 mt-6 overflow-x-auto rounded-lg border bg-black py-4",
className
)}
{...props}
/>
),
code: ({ className, ...props }) => (
<code
className={cn(
"relative rounded border px-[0.3rem] py-[0.2rem] font-mono text-sm",
className
)}
{...props}
/>
),
Image,
Callout,
Card: MdxCard,
};
interface MdxProps {
code: string;
}
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code);
return (
<div className="mdx">
<Component components={components} />
</div>
);
}
-
开始写
mdx
文件根据
Contentlayer
配置文件的配置,现在在根目录创建一个content
文件夹,在里面创建一个about
文件夹,在about
里创建两个mdx
文件,分别叫terms.mdx
和privacy.mdx
,
---
title: Terms
description: Read our terms
---
Term 1.
## Title 1
content content content
---
title: Privacy
description: Read our Privacy
---
Privacy 1.
## Title 1
content content content
-
在
app
文件夹内创建路由文件从
app
开始,创建出这样的路径:app/(about)/[…slug]/page.tsx
如果你想给
mdx
文件页面设置布局,在(about)
文件夹下添加一个layout.tsx
写一下布局,这里仅提供page.tsx
文件的代码
// page.tsx
"use client";
import { notFound } from "next/navigation";
import { allPosts } from "contentlayer/generated";
import { Mdx } from "@/components/mdx/mdx-components";
import "@/styles/mdx.css";
import { Metadata } from "next";
import { siteConfig } from "@/config/site";
import { absoluteUrl } from "@/lib/utils";
interface PageProps {
params: {
slug: string[];
};
}
async function getPageFromParams(params: { slug: string[] }) {
const slug = params?.slug?.join("/");
const page = allPosts.find((page) => page.slugAsParams === slug);
if (!page) {
null;
}
return page;
}
// 下面OG的代码如果适用你的项目,你可以取消注释
// export async function generateMetadata({
// params,
// }: PageProps): Promise<Metadata> {
// const page = await getPageFromParams(params);
// if (!page) {
// return {};
// }
// const url = process.env.NEXT_PUBLIC_APP_URL;
// const ogUrl = new URL(`${url}/api/og`);
// ogUrl.searchParams.set("heading", page.title);
// ogUrl.searchParams.set("type", siteConfig.name);
// ogUrl.searchParams.set("mode", "light");
// return {
// title: page.title,
// description: page.description,
// openGraph: {
// title: page.title,
// description: page.description,
// type: "article",
// url: absoluteUrl(page.slug),
// images: [
// {
// url: ogUrl.toString(),
// width: 1200,
// height: 630,
// alt: page.title,
// },
// ],
// },
// twitter: {
// card: "summary_large_image",
// title: page.title,
// description: page.description,
// images: [ogUrl.toString()],
// },
// };
// }
export async function generateStaticParams(): Promise<PageProps["params"][]> {
return allPosts.map((page) => ({
slug: page.slugAsParams?.split("/"),
}));
}
export default async function PagePage({ params }: PageProps) {
const page = await getPageFromParams(params);
if (!page) {
notFound();
}
return (
<article className="container max-w-3xl py-6 lg:py-12">
<div className="space-y-4">
<h1 className="inline-block font-heading text-4xl lg:text-5xl">
{page.title}
</h1>
{page.description && (
<p className="text-xl text-muted-foreground">{page.description}</p>
)}
</div>
<hr className="my-4" />
<Mdx code={page.body.code} />
</article>
);
}
-
运行代码
此时执行
npm run dev
会发现报错了,是的,还得修改启动命令和打包命令
// package.json
"scripts": {
"dev": "concurrently \"contentlayer dev\" \"next dev\"",
"build": "contentlayer build && next build",
"start": "next start"
},
现在执行启动命令,根目录会出现.contentlayer
文件夹,如果里面的文件夹结构是图片这样的:
那就说明以上配置全部正确。
-
配置一下忽视
.contentlayer
文件夹修改
.prettierignore
和.gitignore
// .prettierignore
……
.contentlayer
// .gitignore
……
.contentlayer
全部配置完成!如果你有新的页面想用mdx来写,只要在content里添加文件就可以。
配置过程有点烦,但是配置完成后是真的香。
和React-markdown有什么区别
有了以上的经验,再看contentlayer
和react-markdown
的区别,可以看出来contentlayer
是页面级别的工具,而react-markdown
是组件级别的工具。
当你的项目有多个md文件时,用contentlayer
显然性价比更高,因为使用react-markdown
除了要写md文件,还要写一个页面组件包裹起来。
而当你需要在组件级别使用md格式,那么要果断使用react-markdown
。
源码
源码正在路上……
我会将这个项目第一个正式版的代码开源出来。