使用 Next.js 14 渲染 Markdown 資料
前言
🔗要在 Next.js 14 實作渲染 Markdown 文件,有很多套件可以使用,經過研究普遍都推薦 Contentlayer
輕量且容易使用,但目前並沒有對應 Next.js 14 的版本,最後選擇使用量也很多的 react-markdown
,包含 gray-matter、rehype-highlight、rehype-raw、remark-gfm
即能實作出基本的 MD 渲染文件頁面。
先下載需要的包
bashnpm 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
JSimport 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;
}
});
}
詳細解釋:
-
const postsDirectory = path.join(process.cwd(), 'posts');
取得posts
資料夾路徑 -
const fileNames = fs.readdirSync(postsDirectory);
若 MD 檔都只放在posts
這層,可直接使用。 -
getMarkdownFiles()
因 MD 檔散佈在不同子資料夾使用遞迴獲取包含子目錄的所有 Markdown 文件。 -
getSortedPostsData()
為取得每篇文章的資料,包含文章的前置資料。 -
額外增加
isDev
來判斷只在測試環境,顯示draft
為false
的資料(還未打算發佈的文章)。 -
額外回傳
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>
-
rehypePlugins
包含了擴充代碼凸顯樣式rehypeHighlight
,與轉換包含在 Markdown 文件裡面的 HTML 語法rehypeRaw
,還有自動為每個 Heading 增加對應的IDrehypeSlug
。 -
remarkPlugins
包含了能顯示表格樣式的remarkGfm
,與自動顯示文章目錄 的remarkToc
。 -
components
則是可以對於特定的 HTML去作額外的修飾增加美觀,可以看到我對 imag 、iframe 等內容都還有去做額外的調整,詳細使用方式本篇不多加敘述。
結論
🔗此篇主要說明了如何取得根目錄的 MD 檔,並藉由 ReactMarkdown 渲染的過程,並簡單描述如何對一些外觀作調整,希望有幫助到你。
Alvin
軟體工程師,喜歡金融知識、健康觀念、心理哲學、自助旅遊與系統設計。
相關文章
留言區 (0)
尚無留言