没事造轮子没事造轮子

没事造轮子

MDX 文件高级渲染

  • W_Z_C
  • 阅读约 6 分钟
MDX 文件高级渲染

这个章节本来不准备写了,毕竟在前面的文章里已经详细的介绍了 Markdown 的渲染流程,但是想了想,还是把 Markdown 文件显示的细节说一说,其实内容并不复杂,只是 Markdown 文件渲染的细节很多,特别是围绕着 Markdown 可以添加很多渲染插件,在渲染的每一步其实都可以自行扩展,主要还是看个人需要,下面就简单的介绍下一目前我博客中的渲染设置,仅供参考。

目前项目中渲染 Markdown 文件的是 mdx-bundler 库,在前面的文章中我只提到 Markdown 文件,但实际上 mdx-bundler 最强大的是后面的 “X”。MDX 是 Markdown 的超集,“X” 代表自定义组件,使用 mdx-bundler 可以直接将包含组件代码的 Markdown 文件渲染成最终的 HTML 代码。

按照 mdx-bundler 库渲染 Markdown 的流程顺序,有几个地方可以修改最终的显示效果:

  1. remark 插件。mdx-bundler 可以绑定 remark 插件,这些插件可以操作 Markdown 的语法树。
  2. rehype 插件。mdx-bundler 可以绑定 rehype 插件,这些插件可以操作 HTML 的语法树。
  3. components 参数。该参数可以通过 MDX 的能力替换现有的组件内容。
  4. typography 设置。这个设置不是 mdx-bundler 的功能,而是 Tailwind CSS 的设置,它可以设置最终文本的显示效果,等价于修改 CSS 代码。

上述步骤中的前两步发生在后端,后两步发生在前端。

1. remark 插件

当前系统中一共使用了两个 remark 插件。一个是 remark-unwrap-images,主要是修改默认 img 标签。MDX 文件在编译的时候,会将下面的图片显示语法:

![Alt text](/logo.png "title")

转换为这样的 HTML 代码:

<>
  <p><img src="/logo.png" alt="Alt text" title="title" /></p>
</>

直接使用上面的 HTML 代码可能会导致 img 图片受到 p 标签的样式影响,使用该插件可以移除外面包裹的 p 标签。

另一个使用的插件是 remark-gfm,该插件主要是扩展 Markdown 的语法。GFM 是 GitHub Flavored Markdown 的简称,表示在 Github 上额外增强的几种常用语法,包括自动链接(www.xxx.com)、页脚([^1])、删除线(~~stuff~~)、表格(| cell |...)以及任务列表(* [x])。通过这个插件可以将 Markdown 中这几种语法转换为对应的 HTML 代码。

加载使用 remark 插件的方法很简单:

init.mjs
async function getMDXList() {
    //...        for (let i = 0; i < files.length; i++) {        const fp = path.join(files[i].dir, files[i].name)
        const { code, frontmatter } = await bundleMDX({            mdxOptions: opts => {                opts.remarkPlugins = [...(opts.remarkPlugins ?? []), remarkUnwrapImages, [remarkGfm, {singleTilde: false}]]                return opts            },            file: fp,            cwd: COMPONENTROOT        })    }
    //...}

2. rehype 插件

系统中目前使用了四个 rehype 插件,都是我个人写的,主要是为了实现博客的一些扩展功能,稳定性值得怀疑,不过等发现具体问题再修复就好。下面简单的介绍一下:

rehypeLinks 插件。 这个插件主要是为了检测当前 Markdown 文档中的链接,如果是外部链接,则加入 _blank 属性。

plugins.mjs
const rehypeLinks = () => {
    return (tree) => {

        function visitor(node) {
            const { tagName, properties: { href } } = node
            
            if (tagName !== 'a' || typeof href !== 'string' || href.length < 1) {
                return
            }

            const scrUrl = new URI(href);
            if (scrUrl.is('absolute')) {
                node.properties.target = '_blank'
                node.properties.rel = 'nofollow noopener'
            }
        }


        visit(tree, ['element'], visitor)
    }
}

rehypeImgSize 插件。 这个插件主要是为了解决图片大小问题,因为 Nextjs 中的 next/image 组件在使用的时候必须传入图片的宽高。所以这个组件主要是为了给图片增加宽高属性,额外的还检测了图片是否存在,不存在则给一个默认的 404 图片替代。

rehypeImgSize 插件代码
plugins.mjs
const rehypeImgSize = () => {
    const PUBLIC_IMAGE_DIR = path.join(process.cwd(), '/public')

    return async (tree) => {
        const tasks = []

        function visitor(node) {
            const { tagName, properties: { src } } = node

            if (tagName !== 'img' || typeof src !== 'string' || src.length < 1) {
                return
            }

            const scrUrl = new URI(src);
            if (scrUrl.is('absolute')) {
                const task = probe(decodeURIComponent(scrUrl), { rejectUnauthorized: false }).then((res) => {
                    if (res && res.width && res.height) {
                        node.properties.width = res.width
                        node.properties.height = res.height
                    }
                })
                tasks.push(task)
            } else {

                let fp = path.join(PUBLIC_IMAGE_DIR, src)
                try {
                    //文件是否存在
                    if (!fs.existsSync(fp)) {
                        console.error(fp)
                        node.properties.src = '/assert/images/404.jpg'
                        fp = path.join(PUBLIC_IMAGE_DIR, node.properties.src)
                    }
                } catch (e) { }

                const task = probe(fs.createReadStream(fp)).then((res) => {
                    if (res && res.width && res.height) {
                        node.properties.width = res.width
                        node.properties.height = res.height
                    }
                })
                tasks.push(task)
            }
        }

        visit(tree, ['element'], visitor)

        await Promise.all(tasks)
    }
}

rehypeToc 插件。 主要是为了提取文章大纲。该插件会递归的提取文章标题,并给文章标题的节点增加 id 和 depth 属性,便于前端大纲的渲染。

rehypeToc 插件代码
plugins.mjs
const rehypeToc = ({ cb, headings = ['h2', 'h3', 'h4'] }) => {
    const DEPTHS = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5 }
    const slugger = new GithubSlugger()

    //判断是否为标题节点
    function isHeadingNode(node) {
        if (node && node.type === 'element' && typeof node.tagName === 'string' && typeof node.properties === 'object') {
            if (!headings.includes(node.tagName)) {
                return false
            }

            return true
        }

        return false
    }

    //递归查找标题
    function findHeadingsRecursive(node, headingNodes) {
        if (isHeadingNode(node)) {
            headingNodes.push(node)
        }

        if (node.children) {
            let parent = node
            for (let i = 0; i < parent.children.length; i++) {
                findHeadingsRecursive(parent.children[i], headingNodes)
            }
        }
    }

    //查找标题
    function findHeadings(tree) {
        let headingNodes = []
        findHeadingsRecursive(tree, headingNodes)
        return headingNodes
    }


    //获取标题文本
    function getInnerText(node) {
        let text = ''

        if (node.type === 'text') {
            text += node.value || ''
        }

        if (node.children) {
            let parent = node
            for (let i = 0; i < parent.children.length; i++) {
                text += getInnerText(parent.children[i])
            }
        }

        return text
    }

    //返回标题文本
    function getTitleText(val) {
        return val.replace(/<(\/)?[^>]+>/g, '').replace(/\s{2,}/g, ' ')
    }

    return (tree) => {
        let headings = findHeadings(tree)
        for (let i = 0; i < headings.length; i++) {
            const titleText = getTitleText(getInnerText(headings[i]))
            const pinyinText = toPinyin(titleText)
            const slugText = slugger.slug(pinyinText)
            const dp = DEPTHS[headings[i].tagName] || 0

            if(slugText && slugText.length > 0) {
                headings[i].properties['id'] = slugText
            
                if (cb) {
                    cb({
                        value: titleText,
                        depth: dp,
                        id: slugText,
                    })
                }
            }
        }
    }
}

这个插件会通过回调的方式返回提取的大纲,最后这个大纲的结果会保存到数据库中,前端加载页面的时候可以直接查询到。

rehypeSyntaxHighlighting 插件。 主要是为了代码高亮使用。该插件可以提取 Markdown 中的代码,并将临近的代码片段提取为一组,最终配合前端显示为类似 Tabs 的效果,实现可以参考 Tailwind CSS 的官网,我也是直接从它们的官网裁剪的代码,具体的实现过程现在已经有点看不太懂了……

使用这些插件的方法和 remark 插件类似:

init.mjs
async function getMDXList() {
    //...        for (let i = 0; i < files.length; i++) {        const fp = path.join(files[i].dir, files[i].name)
        let headings = []        const { code, frontmatter } = await bundleMDX({            mdxOptions: opts => {                opts.remarkPlugins = [...(opts.remarkPlugins ?? []), remarkUnwrapImages, [remarkGfm, {singleTilde: false}]]                opts.rehypePlugins = [...(opts.rehypePlugins ?? []), rehypeLinks, rehypeImgSize, [rehypeToc, {cb: h => {headings.push(h)}}], rehypeSyntaxHighlighting]                return opts            },            file: fp,            cwd: COMPONENTROOT        })    }
    //...}

代码中的 headings 在每次解析完 Markdown 文档后,都会保存当前 Markdown 文档的大纲,不要忘记将该数组保存到数据库中便于前端直接加载。

3. components 参数

上面两处改变 Markdown 文档的方法都是在后端进行的,到目前为止,所有的 Markdown 文档都已就存储到数据库中,接下来显示 Markdown 文档的内容都是直接从数据库中加载的。对于 mdx-bundler 这个第三方库,该库提供了在前端渲染的具体方法:

const MDXContent = React.useMemo(() => getMDXComponent(code), [code])
<MDXContent />

这也是目前项目中使用的渲染方式,为了配合后台的数据显示,可能需要替换一些现有的标签组件,例如 img 元素,改为惰性加载组件、a 链接,使用 Nextjs 提供的 Link 组件、pre 元素,增加 notranslate 类名称,防止 google 翻译的时候自动翻译代码中的文字,除此之外还包含标题元素等等。

具体的实现每个人可能都有所不同,纯粹是前端的样式设置,这里拿 img 元素为例,让其用惰性加载的组件进行替换:

Mdx/index.js
<MDXContent components={{
    img: (props) => {
        return (
            <div className="text-center">
                <LazyLoadImage className="mx-auto" {...props} />
            </div>
        )
    }
}} />

代码中直接修改 components 对象,对象的 key 表示要替换的元素,对象的 value 表示要替换的组件,具体需要替换哪些完全由你自己决定,推荐至少替换 a 元素和 img 元素,因为 Nextjs 官方提供了类似组件,只有替换这些元素才能使用 Nextjs 提供的额外功能。

4. typography 设置

typography 相关设置本质上就是修改 CSS 样式,目前使用的是 @tailwindcss/typography 组件。可以在 tailwind.config.js 配置文件中修改它的默认样式。在前面的文章中,已经提供了一份默认的参考样式,具体可以要查看 Tailwind CSS 官方文档自行修改。