Published on

Contentlayer + Markdown 渲染

Authors
  • avatar
    Name
    Shelton Ma
    Twitter

Contentlayer + Markdown 渲染

1. 安装

pnpm add contentlayer next-contentlayer2
pnpm add date-fns
pnpm add remark-gfm rehype-slug rehype-pretty-code

2. 目录结构

├── components.json
├── contentlayer.config.ts
├── data
│   └── blog
│       ├── en
│       │   └── hi.mdx
│       └── zh
│       │   └── hi_zh.mdx
├── src
│   ├── app
│   │   ├── [locale]
│   │   │   ├── blog
│   │   │   │   ├── [slug]
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   └── page.tsx
│   │   ├── favicon.ico
│   │   └── globals.css
  1. 创建 contentlayer.config.ts

    import { defineDocumentType, makeSource } from "contentlayer/source-files";
    import rehypePrettyCode from "rehype-pretty-code";
    import rehypeSlug from "rehype-slug";
    import remarkGfm from "remark-gfm";
    
    export const Blog = defineDocumentType(() => ({
      name: "Blog",
      filePathPattern: `blog/**/*.mdx`,
      contentType: "mdx",
      fields: {
        title: { type: "string", required: true },
        date: { type: "date", required: true },
        description: { type: "string", required: false },
        slug: {
          type: "string",
          required: true,
        },
        published: { type: "boolean", default: true },
        tags: { type: "list", of: { type: "string" }, required: false },
      },
      computedFields: {
        url: {
          type: "string",
          resolve: (doc) => `/blog/${doc.slug}`,
        },
      },
    }));
    
    export default makeSource({
      contentDirPath: "content",
      documentTypes: [Blog],
      mdx: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [rehypeSlug, rehypePrettyCode],
      },
    });
    
  2. 配置 tsconfig.json, 为了导入 allBlogs

    {
      "compilerOptions": {
        ...
        "paths": {
          ...
          "contentlayer/generated": ["./.contentlayer/generated"]
        },
      },
      ...
      "include": [
        ...
        ".contentlayer/generated",
        ".contentlayer/generated/**/*.json",
        ...
      ],
      ...
    }
    
  3. 创建列表页 src/app/[locale]/blog/page.tsx

    import { allBlogs } from "contentlayer/generated";
    import { compareDesc } from "date-fns";
    import Link from "next/link";
    
    export default function BlogListPage() {
      const posts = allBlogs
        .filter((p) => p.published)
        .sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)));
    
      return (
        <main className="max-w-3xl mx-auto py-16 px-4">
          <h1 className="text-4xl font-bold mb-8">Blog</h1>
          <ul className="space-y-8">
            {posts.map((post) => (
              <li key={post.slug}>
                <Link href={post.url} className="block group">
                  <h2 className="text-2xl font-semibold group-hover:underline">
                    {post.title}
                  </h2>
                  <p className="text-sm text-muted-foreground">{post.date}</p>
                  {post.description && (
                    <p className="mt-1 text-muted-foreground">{post.description}</p>
                  )}
                </Link>
              </li>
            ))}
          </ul>
        </main>
      );
    }
    
  4. 创建详情页 src/app/[locale]/blog/[slug]/page.tsx

    import { allBlogs } from "contentlayer/generated";
    import { format } from "date-fns";
    import { useMDXComponent } from "next-contentlayer2/hooks";
    import { notFound } from "next/navigation";
    
    type Params = {
      params: { slug: string };
    };
    
    export default function BlogDetailPage({ params }: Params) {
      const post = allBlogs.find((p) => p.slug === params.slug);
    
      if (!post) notFound();
    
      const MDXContent = useMDXComponent(post.body.code);
    
      return (
        <article className="max-w-3xl mx-auto py-16 px-4">
          <h1 className="text-4xl font-bold mb-2">{post.title}</h1>
          <p className="text-sm text-muted-foreground mb-8">
            {format(new Date(post.date), "yyyy-MM-dd")}
          </p>
          <div className="prose dark:prose-invert">
            <MDXContent />
          </div>
        </article>
      );
    }
    
  5. 配置 next.config.ts

    import type { NextConfig } from "next";
    import { withContentlayer } from "next-contentlayer";
    import createNextIntlPlugin from "next-intl/plugin";
    
    const nextConfig: NextConfig = {};
    
    const withNextIntl = createNextIntlPlugin();
    export default withNextIntl(withContentlayer(nextConfig));
    

3. 内容添加

在根目录创建 content/blog/ 目录,并添加一个 Markdown 文件 first-post.mdx, 根目录参考 contentlayer.config.ts的配置(contentDirPath: "content",)