在nextjs中渲染Markdown

在nextjs中渲染Markdown


  • mdx 可以作为组件引入到.tsx 中使用,作为组件引入到.tsx 使用时,可以通过 mdx-components.tsx 设置 mdx 样式,也可以使用 mdx 组件的 components 属性设置样式
  • 也可以将组件引入到.mdx 中使用。
  • 也可以使用<div dangerouslySetInnerHTML={{ __html: content }} />或 import { MDXRemote } from "next-mdx-remote-client/rsc 渲染,content 为内容部分。

一、基础配置

1.安装依赖

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

2.配置 next.config.ts

import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
}

const withMDX = createMDX({
  extension: /\.(md|mdx)$/,
})

export default withMDX(nextConfig)

3.在项目根目录新建 mdx-components.tsx 文件并添加以下内容

import type { MDXComponents } from "mdx/types";
import Image, { ImageProps } from "next/image";


const generateId = (text: string) => text + new Date().getTime();
// markdown 解析文件,用于直接导入组件渲染的方式
export function useMDXComponents(components: MDXComponents): MDXComponents {

  return {
    // 自定义h1的样式,并设置一个id,此处设置id的作用是用作锚点定位到相应位置。
    h1: ({ children }) => (
      <h1 id={generateId(children as string)}>{children}</h1>
    ),
    img: (props) => (
      <Image // 使用next/image的Image组件性能更好
        sizes="100vw"
        style={{ width: "100%", height: "auto" }}
        {...(props as ImageProps)}
      />
    ),
    ...components,
  };
}

二、使用

1.在.tsx 组件或页面中使用

{/* 引入.mdx组件 */}
import Me from "./_comp/me.mdx";


function CustomH2({ children }: { children: React.ReactNode }) {
  return (
    <h2 id="test" style={{ color: "red" }}>
      {children}
    </h2>
  );
}
{/* 设置样式 */}
const overrideComponents = {
  h2: CustomH2,
};


{/* 使用 */}
<div className="mx-auto bg-white">
{/* 通过 components={overrideComponents} 设置样式 */}
  <Me components={overrideComponents} />
</div>

2.在<div dangerouslySetInnerHTML={{ __html: content }} />或 中使用

解析文件内容, 得到文档目录(toc)和内容(content)

import path from "path"
import { notFound } from "next/navigation";
import matter from "gray-matter";
import fs from "fs";
import { MdxFiles, TocItem } from "@/types/note";
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import { visit } from 'unist-util-visit';
import { unified } from 'unified'
// import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeSlug from 'rehype-slug' // 给h1~h6设置随机id
import { toString } from 'mdast-util-to-string';
import { generateHash } from "./utils";
import { h } from 'hastscript';
import type { Plugin } from 'unified';
import type { Node, Element, ElementContent } from 'hast';
import type { Heading } from 'mdast';


/**
 * 读取MDX文件内容
 * @param folder - 文件夹路径
 * @param filename - 文件名
 * @returns 处理后的内容
 */
export const readMdxContent = async (folder = "src/noteContent/zh-CN", filename = "readme") => {
    const filePath = path.join(process.cwd(), folder, `${filename}.mdx`);
    let fileContent: string;
    try {
        fileContent = fs.readFileSync(filePath, "utf8");
    } catch (err) {
        console.error(`MDX文件未找到: ${filePath}, ${err}`);
        return notFound();
    }

    const { data, content } = matter(fileContent);
    const toc: TocItem[] = [];

    try {
        const processedContent = await unified()
            .use(remarkParse)
            .use(() => (tree) => {
                visit(tree, 'heading', (node: Heading) => {
                    const headingText = toString(node);
                    const id = generateHash(headingText, 8);
                    let uniqueId = id;
                    let counter = 1;
                    while (toc.some(item => item.id === uniqueId)) {
                        uniqueId = `${id}-${counter}`;
                        counter++;
                    }
                    toc.push({
                        depth: node.depth,
                        value: headingText,
                        id: uniqueId
                    });
                    if (!node.data) node.data = {};
                    if (!node.data.hProperties) node.data.hProperties = {};
                    node.data.hProperties.id = uniqueId;
                });
            })
            .use(remarkRehype)
            .use(rehypeSlug)
            // .use(rehypeAutolinkHeadings, { // 给每个标题添加锚点
            //     behavior: 'wrap',
            //     properties: {
            //         class: 'heading-link',
            //         ariaHidden: 'true',
            //         tabIndex: -1
            //     }
            // })
            .use(rehypeCodeWithCopy) // 添加我们的自定义插件
            .use(rehypeStringify)
            .process(content);

        // 添加复制功能的JavaScript代码
        const copyScript = `
          <script>
            function copyToClipboard(button, text) {
              navigator.clipboard.writeText(text).then(() => {
                const originalText = button.textContent;
                button.textContent = '已复制!';
                button.classList.add('copied');
                setTimeout(() => {
                  button.textContent = originalText;
                  button.classList.remove('copied');
                }, 2000);
              }).catch(err => {
                console.error('复制失败:', err);
                button.textContent = '复制失败';
              });
            }
          </script>
        `;

        return {
            data,
            content: String(processedContent) + copyScript,
            toc
        };
    } catch (error) {
        console.error('处理MDX内容时出错:', error);
        return {
            data,
            content,
            toc: []
        };
    }
}

读取.mdx 文件的“---”中的内容

---
title: '标题'
description: '描述'
date: '2023-08-29 12:15:00'
category: 'javascript'
tags: ['javascript', 'json']
---

获取函数

/**
 * 获取所有mdx文件列表头部内容
 * @param dirPath
 * @param arrayOfFiles
 * @returns
 */
export const getMdxFiles = (dirPath: string = "src/noteContent", arrayOfFiles: MdxFiles[] = []) => {

    // 拿到文件及文件夹数组
    const files = fs.readdirSync(dirPath);
    files.forEach(file => {
        // 判断是否为文件夹
        const fullPath = path.join(dirPath, file);
        if (fs.statSync(fullPath).isDirectory()) {
            getMdxFiles(fullPath, arrayOfFiles);
        } else if (path.extname(file) === '.mdx') {
            const fileContent = fs.readFileSync(fullPath, 'utf8');
            const { data } = matter(fileContent);
            arrayOfFiles.push({
                path: dirPath,
                category: path.dirname(fullPath).split(path.sep).pop(),
                slug: path.basename(file, '.mdx'),
                title: data.title || path.basename(file, '.mdx'),
                description: data.description || '',
                date: data.date || '',
                tags: data.tags || []
            });
        }
    });
    return arrayOfFiles;
}

在页面中使用

// import { MDXRemote } from "next-mdx-remote-client/rsc";
const { content, toc } = await readMdxContent(post.path, slug);
  return (
    <article className="flex">
      <main className="prose mx-auto">
        {/*  渲染内容 可远程获取 */}
        {/* <MDXRemote source={content} /> */}
        {/*  渲染内容 */}
        <div dangerouslySetInnerHTML={{ __html: content }} />
      </main>
      <aside className="w-68 p-4">
        <Catalog toc={toc} />
      </aside>
    </article>
  );