title: 我的博客(笔记)网站是如何搭建的 description: 本文主要介绍了我的博客网站是如何搭建的,主要是使用next.js和mdx来实现。 date: 2024-06-08 16:48:00+8 tags:


1 内容载体选择

基础内容载体的选择,一般而言可以有以下几种选择:

以下对比时基础的设置下的对比:

代表 编写格式 内容存储 个人开销 自由度 评论系统
csdn markdown/富文本 csdn平台 - - 完善
wordpress markdown/富文本 自己服务器 服务器 低,第三方扩展 完善
github pages markdown github - - -
hexo markdown github - 高,可自行修改构建程序 -
nextjs mdx github - 高,可引入react到md -

我之前用过好多种方式,18年左右选择使用,自行集成静态文件,然后托管到github pages,也就是类似hugo的方式,只不过是自己处理的mdhtml的过程。

可以看我19年录制的视频,为什么用这种方式的原因介绍的比较清楚。视频地址。 简单讲,就是我需要一个目录页能快速查找之前记录的笔记。

现在6年过去了,尤其在看国外一些大佬的blog的时候,发现有很多不错的功能,比如说直接把codesandbox这种在线代码工具嵌入进来;直接把自己写一个前端组件嵌入进来;代码高亮直接指定行号高亮等等。这些功能都对于表达清楚文章的内容非常有帮助。

而要实现这么高的自由度,就只能通过服务端渲染的方式来实现了,借助nextmdx,将mdreact整合,打开新世界大门。

当然ssr也有很多其他选择,但是next显然是最成熟的方案,所以最后我选择了nextjs + vercel来部署我的博客。

2 基础配置

我的目的就是为了支持mdx渲染,哦对,简单说一下mdx,就是在md中能插入jsx,而jsxhtml标签的超集,这样换取了超高的自由度。算是在完全自己写一个网页不写网页纯写md 之间做了很好的权衡,即保留前者的自由度,又不会太复杂,导致写文档成为麻烦的事。

mdx

先配置nextjs项目,这里不展开讲基础的创建了,我有个仓库详细展示了如何一步步搭建基于nextjs的项目,可以参考:nextjs-blog。这个文档一共分为5步,对应了5次代码提交。

image

3 Remote MDX

nextjs官方文档也有这一节remote-mdx,是因为步骤2中配置的mdx支持后,最后一步slug路由的时候,发现动态加载只能动态修改文件名,不能动态修改目录名。

这可能与import这个函数比较特殊有关,他应该会在编译的时候就确定目录,不能动态修改目录的范围,估计是为了安全性?这个没仔细研究,但是问题就是如下:

let MDX = dynamic(()=> import(`@/posts/${slug}.md`)); // √ 这是OK的slug动态改变的文件名
let MDX = dynamic(()=> import(`@/${month}/${slug}.md`)); // × 这会报错,变量动态修改了目录就会报错

我当前的结构比较简单就是一个月份目录下面,每个md文件是一篇文章。我当前的目录结构想要前移过来就要大改,比如把24.06/test.md就需要改成24.06_test.md,使其目录不变,仅文件名变化。

<DirectoryTree multiple
defaultExpandAll treeData={[ {title: '18.01', children: [ { title: '1801的第1篇文章.mdx', isLeaf: true}, { title: '1801的第2篇文章.mdx', isLeaf: true},]}, {title: '18.02', children: [{ title: '1802的第1篇文章.mdx', isLeaf: true}, { title: '1802的第2篇文章.mdx', isLeaf: true}, { title: '1802的第3篇文章.mdx', isLeaf: true}]}, ]} />

另一个思路就是写很多switch判断分支,类似下面,虽然用脚本提前生成这段代码也不是不可以,但是总觉得很别扭。

swich(month) {
  case "18.1":
    MDX = dynamic(()=>import(`@/18.1/${slug}.md`))
    break
  case "18.2":
    MDX = dynamic(()=>import(`@/18.1/${slug}.md`))
    break
  ....
  // 这里要把我的所有目录结构都列出来
}

为了避免这样的改造,就需要remote mdx远程mdx的工具,而不是使用原生的mdx支持,所以第二步中配置的mdx相关的支持可以都删除了,直接用第三方的mdx编译工具。

这种工具很多,上面官网介绍了一个第三方的lib: next-mdx-remote,但因为版本兼容性问题,一开始按照官方的demo并没有成功启动,所以我选择了mdx-bundler

img

mdx-bundler除了能将mdx源码编译为jsx代码,还支持import引入外部文。

img

$ npm install --save mdx-bundler esbuild

安装依赖后,接下来要做的是利用nextjs的slug功能,将文章的md文件名作为slug,然后在参数中获取slug即文件名,根据文件名到对应的目录下读取文件的内容,然后用mdx-bundler解析即可。

创建app/blog/[month]/[slug]/page.js文件

import fs from 'fs';
import path from 'path';
import { bundleMDX } from 'mdx-bundler'
import { getMDXComponent } from 'mdx-bundler/client'

export default async function Post({ params }) {
    // 从param中能拿到路径中的month和slug
    let { month, slug } = params;
    // slug对应我们的文件名,因为有中文所以需要反转
    slug = querystring.unescape(slug);

    // 按照md或者mdx后缀去寻找对应的文件系统中的文件,读取文本内容
    var mdxPath = path.join(process.cwd(), month, `${slug}.mdx`);
    var mdPath = path.join(process.cwd(), month, `${slug}.md`);
    const mdxSource = fs.existsSync(mdxPath) ? 
        fs.readFileSync(mdxPath, 'utf8') : 
        fs.readFileSync(mdPath, 'utf8');

    // 用mdx-bundler解析mdx,解析成jsx代码
    const result = await bundleMDX({
        source: mdxSource,
    });

    // code是解析出的jsx代码,frontmatter是解析的前言部分
    const { code, frontmatter } = result

    // 通过getMDXComponent转换解析的code为react组件
    const Component = getMDXComponent(code)
    return (
        <>
            <main>
                <Component />
            </main>
        </>
  )
}

上面代码就可以实现远程mdx的功能,当我们访问http://localhost:3000/blog/24.06/我的博客是如何搭建的这样的网址的时候,路径中的24.06我的博客是如何搭建的会作为params中的monthslug参数传递进来,然后找到24.06我的博客是如何搭建的.md文件;将其内容读取出来后,用mdx-bundlermd文件内容转换为react组件了。

因为在步骤2基础配置中,我们已经引入了github-markdown.css(如果没有引入的话,回看下之前步骤的教程来引入这个css即可),所以页面可以直接渲染成比较好看的样式。

4 插件

正如步骤2中我们引入了很多remarkrehype一样,对于remote形式我们也可以引入这些插件,只需要修改bundleMDX这行代码,通过mdxOptions来增加各种插件,同时增加了cwdesbuildOptions的部分配置,是为了解决一些react组件引入时候的问题。

+ import remarkGfm from 'remark-gfm';
+ import rehypeSlug from 'rehype-slug';
+ import rehypePrismPlus from 'rehype-prism-plus';
+ import toc from "@jsdevtools/rehype-toc";
+ import remarkCodeTitles from "remark-flexible-code-titles";
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings';

+ //注意@/rehypePlugins目录下是自定义的一部分组件后续会说他们的作用
+ import rehypeCodeCopyButton from '@/rehypePlugins/rehype-code-copy-button.mjs'
+ import rehypeImageSrcModifier from '@/rehypePlugins/rehype-image-src-modifier.mjs'
+ import rehypeTocExt from '@/rehypePlugins/rehype-toc-ext.mjs'

-     const result = await bundleMDX({
-         source: mdxSource,
-     });
+     const result = await bundleMDX({
+         source: mdxSource,
+         cwd: path.join(process.cwd(), 'app', 'components'),
+         mdxOptions: (options, frontmatter) => {
+           options.remarkPlugins = [
+                    ...(options.remarkPlugins ?? []), 
+                    ...[remarkGfm, remarkCodeTitles]
+                ]
+           options.rehypePlugins = [
+                    ...(options.rehypePlugins ?? []), 
+                    ...[
+                         rehypeSlug,
+                         [rehypeAutolinkHeadings, { behavior: 'wrap' }],
+                         [rehypePrismPlus, { ignoreMissing: true, showLineNumbers: true }],
+                         rehypeCodeCopyButton,
+                         rehypeImageSrcModifier,
+                         toc,
+                         rehypeTocExt,
+                     ]
+                 ]
+         return options;
+         },
+         esbuildOptions: options => {
+           options.outdir = path.join(process.cwd(), 'public')
+           options.write = true
+           return options
+         }
+     })

上面代码中引入的@/rehypePlugins目录下是我自定义的几个组件,以rehype-toc-ext.mjs为例,我们来看下如何写一个插件,插件本身就是一个函数,函数返回值是(tree)=>{}的函数,其中tree就是当前的mdx的ast树,我们可以对这个ast树进行各种操作,一般会用到visit函数,如下代码:

代码实现了把原来的内容放到新的div中,同时删除原来的nav标签,并添加一个新的nav的div标签,一起放到一个总的div中按照左右排列,实现toc放到右边的效果(原来是上面)

import { visit } from 'unist-util-visit';

export default function rehypeTocExt() {
  return (tree) => {
    let tocNav = null;
    
    // 遍历ast树所有dom节点找到 nav标签,并且class=toc将其删除
    // nav.toc是另一个toc插件生成的dom
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'nav' && node.properties && node.properties.className && node.properties.className.includes('toc')) {
        tocNav = node;
        parent.children.splice(index, 1); 
      }
    });

    // 创建3个div,一个是container,一个是content,一个是toc
    // 第1个 包含 后面2个
    const { children } = tree;
    const container = {
      type: 'element',
      tagName: 'div',
      properties: { className: ['container-wrapper', 'container'] },
      children: [],
    };
    const contentDiv = {
      type: 'element',
      tagName: 'div',
      properties: { className: ['content-wrapper'] },
      children: [],
    };

    // 把tocNav塞到toc中
    const tocDiv = {
      type: 'element',
      tagName: 'div',
      properties: { className: ['toc-wrapper'] },
      children: [tocNav],
    };

    // 把原来tree下面所有节点,塞到content中
    while (children.length > 0) {
      contentDiv.children.push(children.shift());
    }

    container.children.push(contentDiv);
    container.children.push(tocDiv);
    children.push(container);
  };
}

除了插件之外我还增加了一些小的功能,比如代码阅读时间和利用frontmatter展示一些信息,代码如下:

import readingTime from'reading-time'

......
......
    // 这个库根据字数,推算大概需要的阅读时间
    const { text: readingTimeText } = readingTime(mdxSource);
......
    const { code, frontmatter } = result
......

    return 
    <>
     <header>
        {/* 从frontmatter中获取title展示到最上面作为标题 */}
        <h1>{frontmatter.title || slug}</h1>

        {/* 从frontmatter中获取description展示到第2行 */}
        {frontmatter.description && <p>{frontmatter.description}</p>}
        
        <div className='flex gap-4'>
             {/* 阅读时间和tags展示 */}
            <span>{readingTimeText  + (frontmatter.created ? (" created at" + frontmatter.created):  "")}</span>
          <div>
            {frontmatter.tags && frontmatter.tags
                .split(',').filter(item => item.trim().length)
                .map((tag, i) => 
                (<span key={i} 
                    className='bg-green-600 text-white px-2 py-1 rounded-md mr-1 text-sm'>
                    {tag.trim()}
                </span>))}
          </div>
        </div>
        <hr></hr>
      </header>
      <main>
        <Component />
      </main>
    </>

效果是这样的:

image

5 配置提前生成的静态内容

还是在blog/[month]/[slug]/page.js文件最后追加如下,这个函数会在next build的时候被调用,返回值是一个数组,数组中的每一项就是一个路由,会提前把这些路径的静态内容生成到public目录下,这样就可以在next export的时候直接使用这些静态文件,而不需要再次生成,提高了生成速度。

......
export async function generateStaticParams() {
  const path = require('path');
  const POSTS_PATH = process.cwd();
  var filenames = fs.readdirSync(POSTS_PATH);
  // 搜索当前路径下所有的24.06这种格式的路径,这是我存放blog文件的地方
  const monthes = filenames.filter(item => item.match(/\d\d\.\d\d?/));

  // 然后在这个月份路径下面,搜索所有的md和mdx格式的文件
  return monthes.flatMap((month) => {
    const filenames = fs.readdirSync(path.join(POSTS_PATH, month));
    const posts = filenames.filter(item => item.match(/\.mdx?/))
      .map(item => {
        const slug = item.replace(/\.mdx?/, '');
        return { slug, month };
      });
    return posts;
  })
}

6 添加评论区

利用github discussion作为评论区,方式很简单,我这里直接用了https://giscus.app/zh-CN进行配置。

image

image

因为我们是react风格的,直接引入script标签有点怪,所以也可以用giscus提供的react组件,使用也很简单,如下:

$ npm i @giscus/react
'use client'
import Giscus from '@giscus/react';

export default function () {
    return <Giscus
        id="comments"
        repo="sunwu51/notebook"
        repoId="MDEwOlJlcG9zaXRvcnkxMTkxNjk2MzE="
        category="General"
        categoryId="DIC_kwDOBxpiX84CfzJL"
        mapping="url"
        term=""
        reactionsEnabled="1"
        emitMetadata="0"
        inputPosition="top"
        theme="light"
        lang="zh-CN"
        loading="lazy"
    />
}

然后在page.js引入并放到页面最后即可

import Discussion from '@/app/components/Discussion';

......
    <main>
        <Component />
    </main>
    <div className='container mx-auto py-12 px-0'>
        <Discussion />
    </div>

image

评论区的内容是存储到了github discussions中,title就是我们页面的url(因为有中文所以有些转义)

image

7 添加全文搜索功能

这里借助了algolia docsearch提供的免费功能,具体接入流程可以参考如何引入搜索功能到博客

8 添加自定义组件

blog/[month]/[slug]/page.js文件中,我们可以直接使用mdx的自定义组件,例如在写文章中经常会用到的文件结构目录,如果用markdown直接写的话,样式不够美观。就可以从antd中引入。在上面第三步中已经能看到文件目录的结构。

代码就是直接在md文件中写,但是要想这个标签生效,需要引入antd,并将这个组件注册进来。

<DirectoryTree
    multiple        
    defaultExpandAll
    treeData={[
        {title: '文章1的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
        {title: '文章2的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
        {title: '文章3的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
        ]}
    />

安装

$ npm i antd

注意这里不能再page.js直接从antd中引入,因为page.js是服务端渲染的,如果直接引入antd组件,组件中如果使用了React.useState等客户端渲染才有的功能,就会报错。所以先创建了一个Antd.jsx文件:

'use client'
import { Tree } from 'antd'; // 这里还可以引入其他你需要的组件
const { DirectoryTree } = Tree; 

export {  DirectoryTree }

还是修改page.js如下,这样就可以在md文件中直接使用DirectoryTree标签了,就像上面的代码。

import { DirectoryTree } from '@/app/components/Antd';
......
        <main>
            <Component components={{
                DirectoryTree,
                // 如果其他第三方组件要注册,写在这里即可
            }} />
        </main>