实现一个md转vue文件的vite插件
前言
最近写了一个组件库,但还没有相关的文档,如果直接写vue文件太耗时了,因此这边实现一个md转vite的插件帮我快速生成组件文档。 我的设想是直接在md文档中可以编写vue的语法,这样在写md文件的时候就像写支持md语法的vue文件一样简单。
直接上效果:
效果图:
md文件:
# 按钮 Button
demo
color
size
<div>
1232
<HelloWorld name="xx">
</HelloWorld></div>
## 表格
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| block | `boolean` | `false` | 按钮是否显示为块级 |
<HelloWorld>
</HelloWorld>
实现
项目初始化
首先我们要创建一个vue3 + vite 的前端项目。
1.针对我们的md文档我们专门创建一个文件夹,因为这个组件库文档的核心就是这些md代码,因此文件夹命名为core。
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插件的开发。
第一步:
导入@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
方法有两个参数,分别是 code
和 id
。
code
参数:表示要转换的模块的源代码。这是一个字符串,包含了模块的原始内容。id
参数:表示模块的标识符。这是一个字符串,用于唯一标识模块的路径或名称。
基于上面的代码不妨打印一下:
第三步:
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文件的某一段落的代码,我们可以关注一下红框里面的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[] = [] // 获取注册的组件名称列表
下面我们逐个分析这三个全局变量的作用
- 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
...
}
}
}
- 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解析的函数。
- 创建一个
marked.Renderer
实例,并将其赋值给renderer
变量。 - 重写
renderer
的html
方法,用于处理HTML代码。 - 在
html
方法中,使用正则表达式/<([^>\/\s]+)(?:\s+[^>]+)?>/g
匹配HTML代码中的所有标签,并将匹配到的标签存储在templateTags
数组中。 - 遍历
templateTags
数组,将其中的每个标签进行处理,将其存储到components
数组中,并去重。 - 最后,返回原始的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的函数,它接受两个参数: html
和 mdPath
。
函数的作用是根据给定的 html
和 mdPath
构造一个包含模板、组件导入和默认导出的 SFC 字符串。
函数中的 importComponent
部分用来生成组件导入语句,根据 components
数组中的组件名称和 mdPath
构造导入语句。
registerComponent
部分用来生成组件注册语句,将 components
数组中的组件名称拼接成一个以逗号分隔的字符串。
最后,根据生成的组件导入和注册语句,构造一个包含模板和组件信息的 SFC 字符串,并将其作为函数的返回值。
函数中还包含一个辅助函数 getRelativePath
,用于获取相对路径。
它接受两个参数: item
和 mdPath
。
函数的作用是根据 mdPath
和 item
构造一个相对路径,用于组件导入语句中的路径。
到这里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即可,此时尚不支持复杂交互逻辑,待后续更新
插件效果:
转载自:https://juejin.cn/post/7254446353367236663