使用 Next.js 14 渲染 Markdown 資料


June 21, 2024 程式語言

使用 Next.js 14 渲染 Markdown 資料


使用 Next.js 14 與 react-markdown 渲染 Markdown 資料。

前言

🔗

要在 Next.js 14 實作渲染 Markdown 文件,有很多套件可以使用,經過研究普遍都推薦 Contentlayer 輕量且容易使用,但目前並沒有對應 Next.js 14 的版本,最後選擇使用量也很多的 react-markdown,包含 gray-matter、rehype-highlight、rehype-raw、remark-gfm 即能實作出基本的 MD 渲染文件頁面。

先下載需要的包

bash
npm install react-markdown gray-matter rehype-highlight rehype-raw remark-gfm

用途介紹:

react-markdown : 將 Markdown 內容轉換為 HTML 元素。

gray-matter:解析包含前置資料(front matter)的 Markdown 字符串,將前置資料和 Markdown 內容分開,以便在渲染時可以使用前置資料中的信息,如標題、日期、標籤等。

rehype-highlight:一個基於 Rehype 的插件,用於在渲染 Markdown 文件時添加語法高亮。支持多種程式語言,文章若會分享包含程式代碼的內容時非常需要,它會自動識別並凸顯文章中顯示代碼的區域。

rehype-raw:也是屬於 Rehype 插件,用於處理 Markdown 中的原生 HTML。它允許 Markdown 文件中包含並渲染 HTML 標籤。

在撰寫 Markdown 文件時,若需要插入圖片並調整其長寬比,使用純 Markdown 語法是無法實現的。透過 Hugo 建置網站時,我是使用 shortcode 來處理這個問題。經過研究,其實可以直接在 Markdown 文件中嵌入 HTML 語法,然後使用 rehype-raw 轉換並渲染,非常快速且方便。

remark-gfm:屬於 Remark 插件,用於支持 GitHub Flavored Markdown(GFM)的擴展語法。這包括表格、自動鏈接、刪除線等功能。


實作

🔗

取得原始 MD 檔

🔗

主要的功能即為渲染已存在的 Markdown 文件,因為原先 MD 檔的存放路徑並不是單一資料夾,為了方便我選擇以遞迴的方式取得所有檔案。

接下來就是使用 gray-matter 對所有的檔案進行解析並回傳。

路徑 : src\lib\posts.js

JS
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'posts');
// 遞迴函數來獲取目錄和子目錄下的所有 Markdown 文件
function getMarkdownFiles(dir, fileList) {
    const files = fs.readdirSync(dir);

    files.forEach((file) => {
        if (fs.statSync(path.join(dir, file)).isDirectory()) {
            fileList = getMarkdownFiles(path.join(dir, file), fileList);
        } else if (path.extname(file) === '.md') {
            fileList.push(path.join(dir, file));
        }
    });

    return fileList;
}
export function getSortedPostsData() {
    const isDev = process.env.ENV === 'dev';
    // Get file names under /posts
    //   const fileNames = fs.readdirSync(postsDirectory);
    // 獲取包含子目錄的所有 Markdown 文件
    const filePaths = getMarkdownFiles(postsDirectory, []);

    let allPostsData = filePaths.map((filePath) => {
        // Remove ".md" from file name to get id
        const id = path.parse(filePath).name;

        const fileContents = fs.readFileSync(filePath, 'utf8');

        // Use gray-matter to parse the post metadata section
        const matterResult = matter(fileContents);

        // Combine the data with the id
        return {
            id,
            filePath,
            ...matterResult.data,
        };
    });
    if (!isDev) {
        allPostsData = allPostsData.filter(post => post.draft != true);
    }
    // Sort posts by date
    return allPostsData.sort((a, b) => {
        if (a.date < b.date) {
            return 1;
        } else {
            return -1;
        }
    });
}

詳細解釋:

  1. const postsDirectory = path.join(process.cwd(), 'posts');
    取得 posts 資料夾路徑

  2. const fileNames = fs.readdirSync(postsDirectory);
    若 MD 檔都只放在 posts 這層,可直接使用。

  3. getMarkdownFiles()
    因 MD 檔散佈在不同子資料夾使用遞迴獲取包含子目錄的所有 Markdown 文件。

  4. getSortedPostsData()
    為取得每篇文章的資料,包含文章的前置資料。

  5. 額外增加 isDev 來判斷只在測試環境,顯示 draftfalse 的資料(還未打算發佈的文章)。

  6. 額外回傳 filePath 參數原因為我的檔案名稱有些為重複的,最終取捨只好用路徑當ID使用。

以上就能在文章列表或是文章畫面使用此函數取得對應的文章資料。

接下來就要說明如何渲染 MD 文件的內容。

顯示文章內容

🔗

只要透過 getSortedPostsData() 就能取到所有文章的資料,前置資料直接顯示即可,若要顯示內容,因為內容屬於 Markdown 語法,就要使用 react-markdown 轉換為 HTML元素。

代碼

JS

export default async function Page({ params }: { params: { lang: Locale, category: string, post: string } }) {
    const { layout } = await getDictionary(params.lang)
    let post: any = getPostData(params.lang, params.category + "/" + params.post);
    const content = post.content;
    post = post.data;
    return (
        <article className="flex flex-col items-center justify-between min-h-screen max-w-full p-4">
            <div className="mdx max-w-screen-sm w-full break-normal">
                <h1 className="text-3xl font-bold my-6">
                    {post.title}
                </h1>
                <hr />
                <div className="pt-4 pb-4">
                    {post.description}
                </div>
                <ReactMarkdown
                    rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSlug]}
                    components={components}
                    remarkPlugins={[[remarkGfm, { singleTilde: false }], [remarkToc]]}
                >
                    {content}
                </ReactMarkdown>
            </div>
        </article>
    );
}

//The following are adjustments for specific conditions

const components = {
    img: CustomImage,
    iframe: CustomIframe,
    code: CodeBlock,
    h1: (props: any) => <CustomHeading level={1} {...props} />,
    h2: (props: any) => <CustomHeading level={2} {...props} />,
    h3: (props: any) => <CustomHeading level={3} {...props} />,
    h4: (props: any) => <CustomHeading level={4} {...props} />,
    h5: (props: any) => <CustomHeading level={5} {...props} />,
    h6: (props: any) => <CustomHeading level={6} {...props} />,
};



const CustomImage = ({ src, alt, title, width, link }: any) => {

    return (
        <div className="w-full flex flex-col items-center justify-center mt-3 mb-3">
            <div style={{ maxWidth: width, width: 'auto' }}>
                <Image src={src} alt={alt} title={title} width={800} height={800} />
            </div>
            <span>{title}</span>
            {link && <Link href={link}>Source</Link>}
        </div>

    );
};
const CustomIframe = ({ src }: any) => {
    return (
        <div className="max-w-full border-spacing-0 flex justify-center">
            <iframe src={src}
                width="100%"
                height="auto"
                allowFullScreen
                referrerPolicy="no-referrer-when-downgrade"
                loading="lazy">
            </iframe>
        </div>

    );
};

const CodeBlock = ({ node, inline, className, children, ...props }: any) => {
    const match = /language-([\w-#]+)/.exec(className || '');
    return !inline && match ? (
        <>
            <div className="bg-background px-2 py-1 text-sm font-mono">
                <div className="bg-gray-300 text-black inline-block rounded-md px-2">
                    {match[1]}
                </div>
            </div>
            <pre className={`${className} max-w-full overflow-auto rounded-md p-5`} {...props}>
                <code>{children}</code>
            </pre>
        </>

    ) : (
        <code className={`${className} break-words bg-gray-400 text-rose-700 rounded-md`} {...props}>
            {children}
        </code>

    );
};

const CustomHeading = ({ level, children }: { level: number, children: React.ReactNode }) => {
    const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
    let Fragment = "";
    if (typeof children === 'string') {
        Fragment = children as string;
        Fragment = Fragment.replace(/ /g, '-');
    }

    return (
        <>
            {/* 隱藏的錨點,用於滾動定位 */}
            <div id={Fragment} className="relative block -top-20 invisible" />
            {/* 包含鏈接的標題 */}
            <div className="flex items-center group space-x-2">
                <HeadingTag >{children}</HeadingTag>
                <Link href={Fragment && `#${Fragment}`}
                    className="mt-4 inline-flex h-6 w-6 items-center justify-center text-lg text-slate-400 no-underline opacity-0 transition-opacity group-hover:opacity-100 dark:text-slate-400 dark:group-hover:text-slate-300"
                    aria-label="Anchor"
                >
                    🔗
                </Link>
            </div>
        </>

    );
};

詳細解釋:

此為顯示特定文章的代碼,因我的網站有包含多語系,所以會有 lang 參數可以先忽略,主要邏輯為使用類別 category 與文章標題 post 去取得特定文章的內容。

getPostData() 則是寫在 src\lib\posts.js 裡面用來取得特定文章的內容的函數,邏輯也很簡單就只是藉由 getSortedPostsData() 取得所有文章後再藉由給予的條件篩選出對應的文章。至於效能方面,因為我決定每篇文章都會透過 generateStaticParams() 的方式建立 SSG 靜態頁面,所以實際網站運行效能是非常快速的。

接著只要使用

JS
<ReactMarkdown>
    {content}
</ReactMarkdown>

就能渲染 Markdown 內容。

當然實際上並不能渲染全部的 Markdown 內容,包含表格、自動鏈接、刪除線,或是若有代碼區塊,外觀並不符合自己所想的,就可以考慮擴充功能。

JS
<ReactMarkdown
    rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSlug]}
    components={components}
    remarkPlugins={[[remarkGfm, { singleTilde: false }], [remarkToc]]}>
        {content}
</ReactMarkdown>
  1. rehypePlugins
    包含了擴充代碼凸顯樣式 rehypeHighlight,與轉換包含在 Markdown 文件裡面的 HTML 語法 rehypeRaw,還有自動為每個 Heading 增加對應的ID rehypeSlug

  2. remarkPlugins
    包含了能顯示表格樣式的 remarkGfm,與自動顯示文章目錄 的remarkToc

  3. components
    則是可以對於特定的 HTML去作額外的修飾增加美觀,可以看到我對 imag 、iframe 等內容都還有去做額外的調整,詳細使用方式本篇不多加敘述。

結論

🔗

此篇主要說明了如何取得根目錄的 MD 檔,並藉由 ReactMarkdown 渲染的過程,並簡單描述如何對一些外觀作調整,希望有幫助到你。

Next.jsReactWeb



Avatar

Alvin

軟體工程師,喜歡金融知識、健康觀念、心理哲學、自助旅遊與系統設計。

相關文章






留言區 (0)



  or   

尚無留言