likes
comments
collection
share

实现一个md转vue文件的vite插件

作者站长头像
站长
· 阅读数 33

前言

最近写了一个组件库,但还没有相关的文档,如果直接写vue文件太耗时了,因此这边实现一个md转vite的插件帮我快速生成组件文档。 我的设想是直接在md文档中可以编写vue的语法,这样在写md文件的时候就像写支持md语法的vue文件一样简单

直接上效果:

效果图: 实现一个md转vue文件的vite插件 md文件:

# 按钮 Button 
    
demo
color
size

<div>
1232
<HelloWorld name="xx">
</HelloWorld></div>

 ## 表格
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| block | `boolean` | `false` | 按钮是否显示为块级 |

<HelloWorld>
</HelloWorld>

实现

项目初始化

首先我们要创建一个vue3 + vite 的前端项目。

1.针对我们的md文档我们专门创建一个文件夹,因为这个组件库文档的核心就是这些md代码,因此文件夹命名为core。

实现一个md转vue文件的vite插件

2.配置项目的路由文件,将router都指向我们core中的md文件,后续我们再用插件去处理md文件 简单例子:

import { createRouter, createWebHistory } from 'vue-router';
export const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import("@/views/Home.vue"),
    mate: {
        isCore: true
    },
    children: [     
        {
            path: '/button',
            name: 'button',
            component: () => import("core/button.md"),
            mate: {
                en_name: 'button',
                zh_name: '按钮'
            }
        },
        {
            path: '/input',
            name: 'input',
            component: () => import("core/input.md"),
            mate: {
                en_name: 'input',
                zh_name: '输入框'
            }
        }
    ]
  },
]

export default createRouter({
  history: createWebHistory(),
  routes,
})

3.由于md文档主要是为了结合vue组件去展示,因此对于组件我们也需要创建一个文件夹components,下面存储所有需要展示的组件。

实现一个md转vue文件的vite插件

插件实现

上面初始化了项目的基本结构,接下来就开始核心工作,即md转vue插件的开发。

第一步:

导入@vitejs/plugin-vue,即Vite构建工具中的 @vitejs/plugin-vue 插件,用于在Vite项目中处理Vue文件(.vue)和Markdown文件(.md)。

由于路由默认指向的md结尾的markdown文件,Vite默认情况下无法识别和处理Markdown(.md)文件,需要配置 @vitejs/plugin-vue 插件及匹配规则。

import createVuePlugin from '@vitejs/plugin-vue'
const vuePlugin = createVuePlugin({ include: [/\.vue$/, /\.md$/] })

在插件处也同样需要新增该插件

export default defineConfig({
    plugins: [
        vuePlugin
    ]
})

第二步:

创建 vuePluginsMdToVue.ts 文件,用于导出 md转vue 的vite 插件

插件基本结构如下:

export default function vuePluginsMdToVue() {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      // 我们会在transform中进行我们md的转换
    }
  }
}

简单介绍下 vite 插件的 transform 的作用:

Vite自定义插件的 transform方法用于在构建过程中转换模块的内容。具体而言,它允许你对匹配插件配置的文件进行自定义的转换操作。 通过实现transform方法,你可以对模块的内容进行修改、转换或扩展。当Vite构建过程中遇到与插件配置中的include属性匹配的文件时,Vite会调用插件的transform方法来处理这些文件。你可以根据需要对文件内容进行任何自定义的转换操作。

简而言之就是可以对模块去做转换和修改。

Vite自定义插件的 transform 方法有两个参数,分别是 codeid

  1. code 参数:表示要转换的模块的源代码。这是一个字符串,包含了模块的原始内容。
  2. id 参数:表示模块的标识符。这是一个字符串,用于唯一标识模块的路径或名称。

基于上面的代码不妨打印一下:

实现一个md转vue文件的vite插件

第三步:

marked 是一个用于解析md文件的库,插件需要借助marked对md源代码进行分析

1.借助marked.lexer将md转成token数组

借助marked.lexer函数对md文件的源代码进行解析,它的作用是将 Markdown 文本解析为标记化的 token 流, 每个 token 都包含了 Markdown 文本中的一个片段的信息,比如文本内容、标签类型、行号等。

import {marked} from 'marked';

export default function vuePluginsMdToVue() {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
      }
    }
  }
}

同样的不妨基于上面的代码打印一下tokens

实现一个md转vue文件的vite插件

上面的每一项都是对应我们md文件的某一段落的代码,我们可以关注一下红框里面的html部分

2.借助marked.parser将tokens数组转换成html代码

marked.parser 它的作用是将标记化的 token 流转换为 HTML 字符串。 解析过程中,需要先将 Markdown 文本分解为不同的 token,然后根据这些 token 来构建最终的 HTML 结构。

import {marked} from 'marked';

export default function vuePluginsMdToVue() {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
          // tokens数组转为html
          const html = marked.parser(tokens)
      }
    }
  }
}

这样子就可以获得md转出来的html

但md文件中会写vue组件语法,直接的转义没办法将我们md中的vue文件渲染出来,因此我们要创建专门的renderer,对我们md中符合html结构的代码进行解析。

比如我们上面那个红框的html部分,显然 HelloWorld 是一个组件,我们要把md中使用到的 vue 组件都记录下来。

<HelloWorld name="xx">\n</HelloWorld>\n\n

3.定义相关全局变量并赋值

首先我们要创建三个全局的变量components,Path,registerComponentNameList,分别用于 获取md中出现过的所有vue组件名称、 接受存储组件的目录、获取注册的组件名称列表。

let components:any = [] // 存储md中出现的所有vue组件
let Path: object = {} // 存放组件相关地址的目录
let registerComponentNameList: string[] = [] // 获取注册的组件名称列表

下面我们逐个分析这三个全局变量的作用

  1. components:解析完md文件之后会返回一个SFC格式的字符串给vueplugins解析,因此我们要在renderer处获取commponents,用于后续的SFC字符串的拼接,每次执行插件时都要重置components
export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, id:any) {
      components = [] // 重置components
      ...
    }
  }
}
  1. Path:md转vue的过程中,我们会动态的拼接组件导入语句,而不同项目组件目录可能不相同,因此我们需要在使用插件时传入组件文件夹路径,并缓存下来,并且后面拼接SFC时也需要Path去计算相对地址
export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, id:any) {
      components = [] // 重置components
      Path = pathObj // 存储组件相关路径对象
      ...
    }
  }
}

3.registerComponentNameList:基于Path.componentsPath获取当前注册的组件名称列表,后续解析html的时候才知道这个标签是否为组件标签。 定义getRegisterComponentNameList函数通过读取文件的方式去获取注册的组件名称列表

export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      components = [] // 重置components
      Path = pathObj

      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
          
          // 获取所有在组件文件夹下出现的组件名
          registerComponentNameList = getRegisterComponentNameList() // 获取注册的组件名称列表
          ...
      }
    }
  }
}
function getRegisterComponentNameList(): string[]  {
  const list: string[] = []
  fs.readdir(Path.componentsPath, (err, files) => {
    if (err) {
      console.error('无法读取文件夹:', err);
      return list
    }
  
    files.forEach((file) => {
      list.push(path.parse(file).name);
    });
  });
  return list
}

4.针对html类型的token创建renderer解析函数

为了处理上面出现的html代码,因此我们创建一个针对html的renderer,用于解析md中出现的vue组件,并记录出现的vue组件

import {marked} from 'marked';

let components:any = [] // 存储md中出现的所有vue组件
let Path: any = {} // 存放组件相关地址的目录
let registerComponentNameList: string[] = [] // 获取注册的组件名称列表

export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      components = [] // 重置components
      Path = pathObj

      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
          
          // 获取所有在组件文件夹下出现的组件名
          registerComponentNameList = getRegisterComponentNameList() // 获取注册的组件名称列表
          // 将数组转成html
          const html = marked.parser(tokens, {
              gfm: true,    
              renderer: componentRender() 
          });
      }
    }
  }
}

上面传入了自定义的renderer

下面看看如何实现componentRender

解析每一个html代码块中出现的标签,筛选出所有标签名出现在组件文件夹的标签,这些标签就是我们需要处理的vue组件

// 组件html解析
function componentRender(wrapCodeWithCard = true) {
  const renderer = new marked.Renderer()

  renderer.html = function (html:any) {
      // 获取html代码中出现的所有标签
      const regex = /<([^>\/\s]+)(?:\s+[^>]+)?>/g;
      const templateTags = html.match(regex).map((tag:any) => {
          const tagRel = tag.replace(/[<>]/g, '')
          if(tagRel.includes(" ")) {
              return tagRel.split(" ")[0];
          }
          return tagRel
      });
      
      // 将出现的所有vue组件都存储到components中并去重
      components = [...new Set([...components, ...templateTags.filter((tag:any) => registerComponentNameList.includes(tag))])];
      
      return html
  }
  return renderer
}

下面解释下这个函数做了什么?

这个函数是一个用于组件HTML解析的函数。

  1. 创建一个 marked.Renderer 实例,并将其赋值给 renderer 变量。
  2. 重写 rendererhtml 方法,用于处理HTML代码。
  3. html 方法中,使用正则表达式 /<([^>\/\s]+)(?:\s+[^>]+)?>/g 匹配HTML代码中的所有标签,并将匹配到的标签存储在 templateTags 数组中。
  4. 遍历 templateTags 数组,将其中的每个标签进行处理,将其存储到 components 数组中,并去重。
  5. 最后,返回原始的HTML代码。

总体而言,这个函数的作用是解析组件的HTML代码,并将其中出现的Vue组件标签存储到 components 数组中,并去除重复的标签。

执行完这个函数后初始化的components就被成功的赋值了,即获得了md文件中出现的所有vue组件名称

5.基于html和components构造SFC

上面的代码中已经成功获得了基于md文件转换成的html代码,并获取了里面出现的vue组件,所以基于上述条件我们需要去构建SFC格式的字符串。

基于此定义一个SFCRender函数专门用于构造SFC字符串

export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      components = [] // 重置components
      Path = pathObj

      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
          
          // 获取所有在组件文件夹下出现的组件名
          registerComponentNameList = getRegisterComponentNameList() // 获取注册的组件名称列表
          // 将数组转成html
          const html = marked.parser(tokens, {
              gfm: true,    
              renderer: componentRender() 
          });
          
          // 基于html和md文件路径生成SFC
          const SFC = SFCRender(html, path)
          return {code: SFC, map: null}
      }
    }
  }
}

下面我们来看看SFCRender函数如何实现。

/// 构造SFC
function SFCRender(html:string, mdPath:string) {
  const importComponent = components
  .map((item:any) => `import ${item} from '${getRelativePath(item, mdPath)}.vue'`)
  .join('\n')
const registerComponent = components
  .map((item:any) => item)
  .join(',\n')
  const SFC = `
    <template>
        <div>
            ${html}
        </div>
    </template>
    <script>
        ${importComponent}
        export default {
            components: {
              ${registerComponent}
            },
        }
    </script>
  `
  
  return SFC
}

// 获取相对路径
function getRelativePath (item:string, mdPath:string) {
    const filePath = mdPath
    const targetPath = path.join(Path.rootPath, Path.componentsPath + `/${item}`)
    const relativePath = path.relative(path.dirname(filePath), targetPath);
    const componentsRelativePath = relativePath.replace(/\\/g, '/')
    
    
    return componentsRelativePath
}

这里来解释一下上述代码做了什么?

这是一个构造SFC的函数,它接受两个参数: htmlmdPath 。 函数的作用是根据给定的 htmlmdPath 构造一个包含模板、组件导入和默认导出的 SFC 字符串。 函数中的 importComponent 部分用来生成组件导入语句,根据 components 数组中的组件名称和 mdPath 构造导入语句。 registerComponent 部分用来生成组件注册语句,将 components 数组中的组件名称拼接成一个以逗号分隔的字符串。 最后,根据生成的组件导入和注册语句,构造一个包含模板和组件信息的 SFC 字符串,并将其作为函数的返回值。

函数中还包含一个辅助函数 getRelativePath ,用于获取相对路径。 它接受两个参数: itemmdPath 。 函数的作用是根据 mdPathitem 构造一个相对路径,用于组件导入语句中的路径。

到这里md转vue的插件就已经完成了。

插件总结

完整代码

import {marked} from 'marked';
import path from "path";
import fs  from "fs";

let components:any = [] // 存储md中出现的所有vue组件
let Path: any = {} // 存放组件相关地址
let registerComponentNameList: string[] = [] // 获取注册的组件名称列表

export default function vuePluginsMdToVue(pathObj:any) {
  return {
    name: 'vue-plugins-md-to-vue',
    transform(code:any, path:any) {
      components = [] // 重置components
      Path = pathObj

      if (path.endsWith('.md')) {
          // 解析md文档为tokens数组
          const tokens = marked.lexer(code)
          
          // 获取所有在组件文件夹下出现的组件名
          registerComponentNameList = getRegisterComponentNameList() // 获取注册的组件名称列表
          // 将数组转成html
          const html = marked.parser(tokens, {
              gfm: true,    
              renderer: componentRender() 
          });
          
          // 基于html和md文件路径生成SFC
          const SFC = SFCRender(html, path)
          return {code: SFC, map: null}
      }
    }
  }
}

// 组件html解析
function componentRender(wrapCodeWithCard = true) {
  const renderer = new marked.Renderer()

  renderer.html = function (html:any) {
      // 获取html代码中出现的所有标签
      const regex = /<([^>\/\s]+)(?:\s+[^>]+)?>/g;
      const templateTags = html.match(regex).map((tag:any) => {
          const tagRel = tag.replace(/[<>]/g, '')
          if(tagRel.includes(" ")) {
              return tagRel.split(" ")[0];
          }
          return tagRel
      });
      
      // 将出现的所有vue组件都存储到components中并去重
      components = [...new Set([...components, ...templateTags.filter((tag:any) => registerComponentNameList.includes(tag))])];
      
      return html
  }
  return renderer
}

function getRegisterComponentNameList(): string[] {
  const list: string[] = [];
  try {
    const files = fs.readdirSync(Path.componentsPath);
    files.forEach((file) => {
      list.push(path.parse(file).name);
    });
  } catch (err) {
    console.error('无法读取文件夹:', err);
  }
  return list;
}

// 构造SFC
function SFCRender(html:string, mdPath:string) {
  const importComponent = components
  .map((item:any) => `import ${item} from '${getRelativePath(item, mdPath)}.vue'`)
  .join('\n')
const registerComponent = components
  .map((item:any) => item)
  .join(',\n')
  const SFC = `
    <template>
        <div>
            ${html}
        </div>
    </template>
    <script>
        ${importComponent}
        export default {
            components: {
              ${registerComponent}
            },
        }
    </script>
  `
  
  return SFC
}

// 获取相对路径
function getRelativePath (item:string, mdPath:string) {
    const filePath = mdPath
    const targetPath = path.join(Path.rootPath, Path.componentsPath + `/${item}`)
    const relativePath = path.relative(path.dirname(filePath), targetPath);
    const componentsRelativePath = relativePath.replace(/\\/g, '/')
    
    
    return componentsRelativePath
}

插件使用

import path from "path";
import { defineConfig } from 'vite'
import createVuePlugin from '@vitejs/plugin-vue'
import vuePluginsMdToVue from "./src/plugins/vuePluginsMdToVue";
const vuePlugin = createVuePlugin({ include: [/\.vue$/, /\.md$/] })


export default defineConfig({
    resolve: {  
        // 设置目录别名
        alias: {
            // 键必须以斜线开始和结束
            '@': path.resolve(__dirname, './src'),
            'components': path.resolve(__dirname, './src/components'),
            'core': path.resolve(__dirname, './src/core'),
            'assets': path.resolve(__dirname, './src/assets'),
            'interface': path.resolve(__dirname, './src/interface'),
            'plugins': path.resolve(__dirname, './src/plugins'),
        },
    },
    plugins: [
        vuePluginsMdToVue({componentsPath:'./src/components',rootPath:__dirname}),
        vuePlugin
    ]
});

插件文档

参数类型是否必传描述
pathObj对象true组件文件夹目录,根目录{componentsPath:'./src/components',rootPath:__dirname}

注:md文件中使用的组件标签必须 和 组件文件夹下的文件名一致 如:

# 按钮 Button 
    
demo
color
size

<div>
1232
<HelloWorld name="xx">
</HelloWorld></div>

 ## 表格
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| block | `boolean` | `false` | 按钮是否显示为块级 |

<HelloWorld>
</HelloWorld>

HelloWorld 对应的组件文件名必须是 HelloWorld

使用时直接在md文档中编写html即可,此时尚不支持复杂交互逻辑,待后续更新

插件效果: