likes
comments
collection
share

让contentlayer帮你把md文件变成静态页面吧

作者站长头像
站长
· 阅读数 18

Contentlayer 是什么

Contentlayer 是一个功能强大的静态网站生成器,专为构建和管理静态页面、网站和博客而设计。它提供了一种简单而灵活的方式来创建和组织内容,可以将Markdown文件转换为静态HTML页面。

Contentlayer 有什么用

  • **内容建模:**Contentlayer 有一个配置文件,你可以定义不同类型的内容,如文章、页面、作者等,并为每个类型定义字段和关联关系。
  • **Markdown支持:**Contentlayer 使用Markdown文件作为内容源,它可以把你写的MDX文件解析为HTML,并生成静态页面或嵌入到其他静态页面中。
  • **静态网站生成:**如上所述,你也可以利用Markdown支持轻松生成和更新你的静态网站。
  • **自定义渲染:**Contentlayer 允许你自定义React组件来替换或扩展默认的Markdown渲染器,以实现更多你想要的样式。

使用步骤

Contentlayer 使用方法乍一看容易一头雾水,用完之后又容易忘记一些细节,所以需要记录下使用步骤。

以下示例基于NextJS 13.x版本的app router实现。

  1. 安装依赖
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

如果你想问这些rehyperemark插件都是什么作用,请猛击👉一些好用的Markdown优化插件

  1. 创建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",
                  },
                },
              ],
            ],
          },
        })

  1. 添加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;
        }
        
  1. 编辑tsconfig.json

    "paths" 字段用于配置模块解析的路径映射

    "include" 字段用于指定要包含在编译过程中的文件或目录

    在这两个字段里添加contentlayer的配置


        {
        ……
        "paths": {
        	……
          "contentlayer/generated": ["./.contentlayer/generated"] // 添加本行,这是后面markdown文件编译后存放的目录
        },
        ……
        "include": [
          ……
          ".next/types/**/*.ts" // 添加本行
        ],
        }
  1. 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)
  1. 配置基本完成了,现在开始写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>
          );
        }
  1. 开始写mdx文件

    根据Contentlayer配置文件的配置,现在在根目录创建一个content文件夹,在里面创建一个about文件夹,在about里创建两个mdx文件,分别叫terms.mdxprivacy.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
  1. 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>
          );
        }
  1. 运行代码

    此时执行npm run dev会发现报错了,是的,还得修改启动命令和打包命令

// package.json
"scripts": {
    "dev": "concurrently \"contentlayer dev\" \"next dev\"",
    "build": "contentlayer build && next build",
    "start": "next start"
  },

现在执行启动命令,根目录会出现.contentlayer文件夹,如果里面的文件夹结构是图片这样的:

让contentlayer帮你把md文件变成静态页面吧

那就说明以上配置全部正确。

  1. 配置一下忽视.contentlayer文件夹

    修改.prettierignore.gitignore

// .prettierignore

……
.contentlayer

// .gitignore

……
.contentlayer

全部配置完成!如果你有新的页面想用mdx来写,只要在content里添加文件就可以。

配置过程有点烦,但是配置完成后是真的香。

和React-markdown有什么区别

有了以上的经验,再看contentlayerreact-markdown的区别,可以看出来contentlayer是页面级别的工具,而react-markdown是组件级别的工具。

当你的项目有多个md文件时,用contentlayer显然性价比更高,因为使用react-markdown除了要写md文件,还要写一个页面组件包裹起来。

而当你需要在组件级别使用md格式,那么要果断使用react-markdown

源码

源码正在路上……

我会将这个项目第一个正式版的代码开源出来。