Skip to content

给 Next.js 项目添加博客功能(Cloudflare Pages)

Published: at 07:02 AM

使用 Next.js 创建完网站后,可以使用如下方法快速添加博客功能,博客使用 Markdown 文件存储。

也可以使用这套方法快速添加其他内容,如隐私条款、利用规约等。

这篇文章所讲的方法适用于所有部署(不仅限于 Cloudflare Pages,使用 Vercel 部署也可以)。

与这篇文章(给 Next.js 项目添加博客功能)的方法有什么区别?

因为 Cloudflare Pages 部署 Next.js 项目时,使用的是 Edge Runtime,无法使用 Node.js 的 fs 模块读取文件,所以这篇文章主要是讲另一种方法。

核心原理:在构建时,将 Markdown 文件解析并生成 JSON 文件,然后在 Edge Runtime 读取 JSON 文件实现。

Table of contents

Open Table of contents

添加依赖

pnpm add gray-matter next-mdx-remote
pnpm add -D @tailwindcss/typography
pnpm add lucide-react
  • gray-matter: 解析 Markdown 文件元数据
  • next-mdx-remote:渲染 Markdown 文件
  • @tailwindcss/typography:添加 Tailwind Typography 插件,用于渲染 Markdown 文件

配置 tailwind.config.ts

// tailwind.config.ts
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography"; <-- Add this line

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundImage: {
        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
        "gradient-conic":
          "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
      },
    },
  },
  plugins: [typography], <-- Modify this line
};
export default config;

创建示例 Markdown 文章

我们将会创建以下示例文章,注意目录结构:

posts/
  blog/
    welcome.md
  about-us.md
  privacy-policy.md
  terms-of-service.md

下面分别开始创建这些文件:

// posts/about-us.md

---
title: "About Us"
slug: "about"
date: "2024-05-15"
author: "Alex Bennet"
description: "About Us"
---

# About Us

about us
// posts/privacy-policy.md

---
title: "Privacy Policy"
slug: "privacy"
date: "2024-05-15"
author: "Alex Bennet"
description: "privacy policy"
---

# Privacy Policy

privacy policy
// posts/terms-of-service.md

---
title: "Terms of Service"
slug: "terms"
date: "2024-05-15"
author: "Alex Bennet"
description: "terms of service"
---

# Terms of Service

terms of service
# // posts/blog/welcome.md

---
title: "welcome"
slug: "welcome"
date: "2024-05-15"
author: "Alex Bennet"
description: "Welcome to my blog"
---

welcome

创建 Markdown 读取配置

文章开头介绍了,这里要先把 Markdown 文件解析成 JSON 文件,然后直接读取 JSON 文件实现,避免使用 fspath 这些在 Edge Runtime 不可用的模块。

创建解析脚本

// scripts/generate-posts-data.js

const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');

const postsDir = path.join(process.cwd(), 'posts');
const outputDir = path.join(process.cwd(), 'lib', 'posts');
const blogOutputDir = path.join(outputDir, 'blog');
const blogDir = path.join(postsDir, 'blog');

function ensureDirectoryExistence(filePath) {
    const dirname = path.dirname(filePath);
    if (fs.existsSync(dirname)) {
        return true;
    }
    ensureDirectoryExistence(dirname);
    fs.mkdirSync(dirname);
}

function processMarkdownFile(filePath) {
    const content = fs.readFileSync(filePath, 'utf8');
    const { data, content: markdownContent } = matter(content);
    return {
        metadata: {
            ...data,
        },
        content: markdownContent
    };
}

function generateBlogData() {
    const blogFiles = fs.readdirSync(blogDir).filter(file => file.endsWith('.md'));
    const blogPosts = blogFiles.map(file => {
        const filePath = path.join(blogDir, file);
        const { metadata, content } = processMarkdownFile(filePath);
        return {
            slug: path.parse(file).name,
            metadata: { ...metadata, slug: path.parse(file).name },
            content
        };
    });

    blogPosts.sort((a, b) => new Date(b.metadata.date) - new Date(a.metadata.date));

    const blogMetadata = blogPosts.map(post => post.metadata);
    const blogContent = Object.fromEntries(blogPosts.map(post => [post.slug, { metadata: post.metadata, content: post.content }]));

    const metadataPath = path.join(blogOutputDir, 'blog-metadata.json');
    const contentPath = path.join(blogOutputDir, 'blog-content.json');

    ensureDirectoryExistence(metadataPath);
    ensureDirectoryExistence(contentPath);

    fs.writeFileSync(metadataPath, JSON.stringify(blogMetadata, null, 2));
    fs.writeFileSync(contentPath, JSON.stringify(blogContent, null, 2));

    console.log(`Blog metadata generated at: ${metadataPath}`);
    console.log(`Blog content generated at: ${contentPath}`);
}

function generateOtherPostData(dir = postsDir) {
    const items = fs.readdirSync(dir, { withFileTypes: true });

    for (const item of items) {
        const itemPath = path.join(dir, item.name);

        if (item.isDirectory()) {
            if (item.name !== 'blog') {
                generateOtherContentData(itemPath);
            }
        } else if (item.isFile() && item.name.endsWith('.md')) {
            const { metadata, content } = processMarkdownFile(itemPath);
            const relativePath = path.relative(postsDir, itemPath);
            const outputPath = path.join(outputDir, `${path.parse(relativePath).name}.json`);

            ensureDirectoryExistence(outputPath);
            fs.writeFileSync(outputPath, JSON.stringify({ metadata, content }, null, 2));
            console.log(`Generated: ${outputPath}`);
        }
    }
}

// Delete the output directory if it exists
console.log(`🚀 Cleaning up ${outputDir}...`);
if (fs.existsSync(outputDir)) {
    fs.rmSync(outputDir, { recursive: true, force: true });
}

// Ensure output directories exist
console.log(`🚀 Creating ${outputDir} and ${blogOutputDir}...`);
ensureDirectoryExistence(outputDir);
ensureDirectoryExistence(blogOutputDir);

// Generate blog data
console.log('🚀 Generating blog data...');
generateBlogData();

// Generate other post data
console.log('🚀 Generating other post data...');
generateOtherPostData();

console.log('✅ Post data generation completed.');

这个脚本其实现的目的就是将 Markdown 文件解析成 JSON 文件,然后生成如下目录结构:

lib/
  posts/
    blog/
      blog-metadata.json <-- blog 元数据列表,包含 /posts/blog/ 目录下所有md文章的元数据,用于展示博客列表
      blog-content.json <-- blog 内容列表,包含 /posts/blog/ 目录下所有md文章的内容
    about-us.json
    privacy-policy.json
    terms-of-service.json

为什么不直接生成一个 JSON 文件?

这是为了性能优化!博客列表页面是服务端渲染,而博客文章页面是编译时静态生成,如果全都在一个 JSON 文件里,那全部加载所有博客内容到内存会影响性能,首次打开页面会很慢。

配置解析脚本

{
  ...
  "scripts": {
    "posts": "node scripts/generate-posts-data.js", <-- Add this line
    "dev": "pnpm posts && next dev", <-- Modify this line
    "build": "pnpm posts && next build", <-- Modify this line
    "start": "pnpm posts && next start", <-- Modify this line
    "lint": "pnpm posts && next lint", <-- Modify this line
    "pages:build": "pnpm posts && pnpm next-on-pages", <-- Modify this line
    "preview": "pnpm pages:build && wrangler pages dev",
    "deploy": "pnpm pages:build && wrangler pages deploy",
    "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
  },
  ...
}

其实就是给 devbuildstartlintpages:build 命令添加了 posts 命令,这样每次执行这些命令时都会先生成 JSON 文件。

运行解析脚本生成博客 JSON 文件

pnpm posts

运行成功后,将会在 lib/posts 目录下生成一批 JSON 文件。

记得在 .gitignore 里把自动生成的 JSON 文件过滤掉

# script auto-generated files
lib/posts

从 JSON 文件读取博客数据

// lib/posts.ts

interface PostMetadata {
    title: string;
    slug: string;
    date: string;
    author: string;
    description: string;
}

interface PostData {
    metadata: PostMetadata;
    content: string;
}

type BlogContent = Record<string, PostData>;

export async function getBlogMetadataList(): Promise<PostMetadata[]> {
    const { default: blogMetadata } = await import('./posts/blog/blog-metadata.json');
    return blogMetadata as PostMetadata[];
}

export async function getBlogData(slug: string): Promise<PostData | undefined> {
    const { default: blogContent } = await import('./posts/blog/blog-content.json');
    return (blogContent as BlogContent)[slug];
}

export async function getAllBlogSlugs(): Promise<string[]> {
    const blogMetadata = await getBlogMetadataList();
    return blogMetadata.map(post => post.slug);
}

export async function getAboutPost(): Promise<PostData> {
    const { default: contentData } = await import(`./posts/about.json`);
    return contentData;
}

export async function getPrivacyPolicyPost(): Promise<PostData> {
    const { default: contentData } = await import(`./posts/privacy-policy.json`);
    return contentData;
}

export async function getTermsOfServicePost(): Promise<PostData> {
    const { default: contentData } = await import(`./posts/terms-of-service.json`);
    return contentData;
}

创建博客展示页面

创建私隐私条款页面

// app/privacy/page.tsx

import { metaConfig } from "@/config/site";
import { getPrivacyPolicyPost } from "@/lib/posts";
import { Metadata } from "next";
import { MDXRemote } from "next-mdx-remote/rsc";

export const metadata: Metadata = {
    title: `XXX Policy | ${metaConfig.name}`,
    description: metaConfig.description,
    alternates: {
        canonical: metaConfig.base + 'privacy',
    }
};

export default async function PrivacyPage() {
    const post = await getPrivacyPolicyPost();

    return (
        <div className="flex flex-col">
            <main className="w-full max-w-5xl mx-auto p-4 md:p-6 flex-grow">
                <article>
                    <div className="max-w-none prose dark:prose-invert">
                        <MDXRemote source={post.content} />
                    </div>
                </article>
            </main>
        </div>
    );
}

创建利用规约页面

// app/terms/page.tsx

import { metaConfig } from "@/config/site";
import { getTermsOfServicePost } from "@/lib/posts";
import { Metadata } from "next";
import { MDXRemote } from "next-mdx-remote/rsc";

export const metadata: Metadata = {
    title: `XXX of Service | ${metaConfig.name}`,
    description: metaConfig.description,
    alternates: {
        canonical: metaConfig.base + 'terms',
    }
};

export default async function TermsPage() {
    const post = await getTermsOfServicePost();

    return (
        <div className="flex flex-col">
            <main className="w-full max-w-5xl mx-auto p-4 md:p-6 flex-grow">
                <article>
                    <div className="max-w-none prose dark:prose-invert">
                        <MDXRemote source={post.content} />
                    </div>
                </article>
            </main>
        </div>
    );
}

创建博客列表页面

// app/blog/page.tsx

import Link from 'next/link';
import { getBlogMetadataList } from '@/lib/posts';
import { Calendar, User } from 'lucide-react';
import { Metadata } from 'next';
import { metaConfig } from '@/config/site';

export const metadata: Metadata = {
    title: `XXX Blog | ${metaConfig.name}`,
    description: metaConfig.description,
    alternates: {
        canonical: metaConfig.base + 'blog',
    }
};

export default async function BlogList() {
    const postMetadataList = await getBlogMetadataList();

    return (
        <main className="w-full max-w-5xl mx-auto p-4 md:p-6 flex-grow">
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                {postMetadataList.map((metadata) => (
                    <Link href={`/blog/${metadata.slug}`} key={metadata.slug}>
                        <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 overflow-hidden">
                            <div className="p-6">
                                <h2 className="text-xl font-semibold mb-2 hover:text-blue-600 transition-colors duration-300">
                                    {metadata.title}
                                </h2>
                                {/* <p className="text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
                                    {post.metadata.description || "No description available"}
                                </p> */}
                                <div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
                                    <span className="flex items-center mr-4">
                                        <Calendar className="mr-1 h-4 w-4" />
                                        {metadata.date}
                                    </span>
                                    <span className="flex items-center">
                                        <User className="mr-1 h-4 w-4" />
                                        {metadata.author}
                                    </span>
                                </div>
                            </div>
                        </div>
                    </Link>
                ))}
            </div>
        </main>
    );
}

创建博客文章页面

// app/blog/[slug]/page.tsx

import { MDXRemote } from 'next-mdx-remote/rsc';
import { getAllBlogSlugs, getBlogData } from '@/lib/posts';
import { Calendar, User, ArrowLeft } from 'lucide-react';
import { Metadata } from 'next';
import { metaConfig } from '@/config/site';
import Link from 'next/link';

export const dynamicParams = false;

export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const slug = params.slug
    const postData = await getBlogData(slug);
    return {
        title: `${postData?.metadata.title} | ${metaConfig.name}`,
        alternates: {
            canonical: `${metaConfig.base}blog/${slug}`,
        }
    }
}

interface Props {
    params: {
        slug: string;
    };
}

export async function generateStaticParams() {
    const slugs = await getAllBlogSlugs();
    return slugs.map(slug => ({ slug }));
}

export default async function BlogPost({ params }: Props) {
    const post = await getBlogData(params.slug);

    if (!post) {
        return <div>Post not found</div>;
    }

    return (
        <main className="w-full max-w-5xl mx-auto p-4 md:p-6 flex-grow">
            <article>
                <header className="mb-8 text-center">
                    <h1 className="text-4xl font-bold mb-4">{post.metadata.title}</h1>
                    <div className="flex flex-wrap justify-center items-center space-x-4 text-sm text-muted-foreground mb-4">
                        <span className="flex items-center">
                            <Calendar className="mr-1 h-4 w-4" />
                            {post.metadata.date}
                        </span>
                        {post.metadata.author && (
                            <span className="flex items-center">
                                <User className="mr-1 h-4 w-4" />
                                {post.metadata.author}
                            </span>
                        )}
                    </div>
                </header>
                <div className="max-w-none prose dark:prose-invert">
                    <MDXRemote source={post.content} />
                </div>
            </article>
            <Link
                href="/blog"
                className="flex items-center my-8 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-300">
                <ArrowLeft className="mr-2" size={20} />
                <span className="underline">Back</span>
            </Link>
        </main>
    );
}

验证

访问以下 url 验证这些页面是否正常工作: