在markdown中支持可交互组件

编写markdown文档时,有时候希望直接展示一个可交互的组件,类似于ElementUIAntd等主流的组件库的文档交互。由于vite已经逐渐取代webpack称为组件库开发的流行方式,本文将研究一种使用vite插件实现在markdown中实现可交互组件的思路。

<!--more-->

比如下面的markdown文件

# hello

这个是vue md 文件

```vue
import Button from './components/Button.vue'
```

期望在渲染这个文件的时候,能够将vue代码块作为一个组件展示。

这在编写各种组件库文档时是比较方便的。

本项目完整源码

参考

1. 处理markdown文件中的vue组件

要达到这个目的,就需要在解析的时候,将这种特殊的代码标记出来,然后将整个markdown作为一个组件文件导出。

vite插件的transform钩子可以实现这个功能。

markdown转html可以用现成的工具markdown-it,这样我们就可以将md文件当做是vue组件了

const markdown = require("markdown-it");

function transformMarkdown(source) {
    const md = markdown({
        html: true,
    });
    const html = md.render(source);

    // 拼一个sfc文件,然后交给vue插件处理相关的问题
    const template = `
<template>
    ${html}
</template>
    `;
    return {
        code: template,
    };
}

module.exports = function () {
    return {
        name: "vite-plugin-markdown-extend",
        enforce: "pre",
        transform(source, id) {
            if (!/\.md$/.test(id)) {
                return;
            }

            return transformMarkdown(source);
        },
    };
};

拼完vue组件后,只需要使用vue插件进行解析和编译即可(可以通过vite vue插件的inclue配置)

然后在vite.config中配置插件

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import md from "vite-plugin-markdown-extend";

export default defineConfig({
    plugins: [
        md(),
        vue({
            include: [/\.vue$/, /\.md$/], // 注意这里把md文件也包含进来
        }),
    ],
});

然后就可以把markdown文件当做vue组件引入了

import { createApp } from 'vue'

import readme from './readme.md'

createApp(readme).mount('#app')

2. 解析markdown中的组件

在上面将md文件转成了vue组件,其核心思路就是markdown文件->html字符串->sfc文件->vue插件处理vue组件。

按照这个思路,如果要渲染markdown中的vue代码块,只需要在拼html字符串的时候,将代码块作为一个组件标签替换就行了

首先约定一下格式,比如下面这段markdown中的代码

# hello

这个是test md 文件

```vue
import Button from './components/Button.vue'
```

渲染出来的模板字符串为

<h1>hello</h1>
<p>这个是vue md 文件</p>
<Button />

其中vue的代码块会被编译成<Button></Button>,这样就可以把代码块当做一个正常的Vue组件渲染出来了。

我们来实现这个解析

function parseComponent(source) {
    const reComponent = /import (.+) from (['"]).+(\2)/g;
    const reCodeBlock = /```vue((.|\r|\n)*?)```/g;
    const imports = new Set();
    const components = new Set();

    source = source.replace(reCodeBlock, (_, code) => {
        return code.replace(reComponent, (match, $1) => {
            imports.add(match);
            components.add($1);
            return `<${$1} />`;
        });
    });

    return {
        imports: Array.from(imports),
        components: Array.from(components),
        source,
    };
}

这样解析就可以获得

{
  imports: [ "import Button from './components/Button.vue'" ], // 用在script头部引入
  components: [ 'Button' ], // 组件名称,用在components局部组件配置项
  source: '# hello\n\n这个是test md 文件\n\n\n<Button />\n' // 模板文件
}

得到了这些数据,再来拼接sfc模板

function markdown2vue(source) {
    const { source: fSource, imports, components } = parseComponent(source);

    const md = markdown({
        html: true,
    });

    const html = md.render(fSource);

    const template = `
<template>${html}</template>

<script>
// 引入依赖
${imports.join("\n")}

export default {
    components: {
        ${components.join(",")} // 注册局部组件
    }
}
</script>
    `;

    return {
        code: template,
    };
}

最后得到的sfc文件就是

<template>
    <h1>hello</h1>
    <p>这个是test md 文件</p>
    <p>按钮</p>
    <Button />
</template>

<script>
import Button from './components/Button.vue'

export default {
    components: {
        Button
    }
}
</script>

然后再把这个文件交给vite/vue处理就可以了,最后渲染结果如下图所示

大功告成!!

3. 处理markdown中的react组件

我们可以按照相同的思路来支持在markdown中展示react组件。

其思路基本一致:markdown->含组件标记的html字符串->拼接(j|t)sx文件->vite/react插件

按照这个思路实现时,碰到了一个问题是@vitejs/plugin-react传入的inclue参数不支持选择其他类型的文件,比如下面的vite.config.js配置

react({
    include: [/\.jsx$/, /\.md$/],
}),

传入的\.md$配置项并不会生效,导致在插件中将md文件转成jsx之后,会提示js语法错误。

因此不能简单的像vue那样直接把md文件转成jsx文件,

在插件中将md文件转成jsx之后,会提示js语法错误,还需要手动通过babel将jsx编译成js

const markdown = require("markdown-it");
const babel = require("@babel/core");
const jsx = require("@babel/plugin-transform-react-jsx");
const importMeta = require("@babel/plugin-syntax-import-meta");

function markdown2react(source) {
    const { source: fSource, imports, components } = parseComponent(source);

    const md = markdown({
        html: true,
    });

    const html = md.render(fSource);

    const template = `
import React from 'react';
${imports.join("\n")}
export default ()=>{
return (<div>${html}</div>)
}
`;

    const plugins = [
        importMeta,
        jsx,
    ];
    const result = babel.transformSync(template, {
        babelrc: false,
        ast: true,
        plugins,
        sourceMaps: false,
        sourceFileName: "123",
        configFile: false,
    });

    return {
        code: result.code,
        map: result.map,
    };
}

需要注意的是,输出的js文件中需要手动加入import React from 'react';

剩下的流程就与Vue的基本一致了,我们甚至还可以扩展用来支持其他框架语法的功能。

4. 一些约定

上面展示了Button组件的基础使用。在实际项目中,组件往往提供了各种props,如何展示组件的props呢?

一种方案是增加新的语法标记,支持传入props、监听event等,这需要扩展新的标记,也不太方便在markdown中维护

因此为了简单,我们约定markdown组件只负责引入,不负责具体的使用。

如果需要展示下面类型的组件使用

<Button color="blue"></Button>

需要在一个vue文件中编写对应的代码,比如这个文件叫BlueButton.vue

<template>
    <Button color="blue"></Button>
</template>

然后再markdown中编写如下代码,将demo组件引入进来即可

```vue
import BlueButton from './components/BlueButton.vue'
```

5. 小结

本文实现了一种在markdown文件中直接展示vue或react组件的思路。主要是借助vite的transform钩子,将md文件替换成对应框架可以识别的组件,然后通过注册局部组件的方式渲染出来。

感觉整个思路还是比较清晰的,接下来可以尝试写一个类似于vitepress的脚手架工具,快速编写可交互的markdown文档。

Docker部署monorepo项目富文本编辑器Quill源码分析