手写Vite插件解析Markdown文件成Vue组件
由于最近在开发一个基于Vite构建的Vue3的组件库,组件文档页面那一块需要使用Markdown文档来显示内容和组件的使用案例,一开始我在网上找了一些插件来去使用,后来发现找了挺多的插件都只是单纯的转换Markdown文档,而我这里是想把组件的代码也加到Markdown里面去,让Markdown文件以一个页面的形式显示,并且组件要能够正常的使用。于是干脆就自己写一个插件来解析Markdown文件。
实现思路
先看看vite官方创建插件的API,后面将通过其中的一些钩子来实现Markdown文件的转换。
首先创建一个index.js文件来作为插件的主文件,并把这个文件引入到vite.config.js里面使用:
// plugin/index.js
export default function myPlugin(options = {}) {
return {
name: 'my-plugin',
enforce: 'pre',
transform: function(code, id) {
}
}
}
// vite.config.js
import vue from '@vitejs/plugin-vue'
import myPlugin from './plugin/index.js'
export default {
plugins: [
vue({
include: [/(\.vue)$/,/\.md$/]
}),
myPlugin(),
]
}
name:插件的名称
enforce:插件调用时机
transform:该钩子会在每个传入模块请求时被调用
我这里只用了这三个参数,其它的参数和使用方法请查看官方文档。
现在就已经把这个插件引进来了,但内容还是空的,所以必须要让它做点什么。
知道了插件怎么使用之后,就来看一下解析Markdown的具体思路:
- 先新建Markdown文件,并填写相关内容。
- 把Markdown文件通过路由引入,以页面的形式展示。
- 然后通过插件
index.js的transform钩子截取相应的.md文件。 - 截取到内容后,把相应的内容通过
markdown-it转换成html,再把对应内容解析成Vue组件。
具体步骤
1.新建 test.md 文件,并输入相关内容
## 这是一个Markdown文件
#### 下面的内容是解析Vue组件的写法
:::demo
```html
<my-input v-model="msg" placeholder="请输入内容"></my-input>
<script>
import {ref, defineComponent} from 'vue'
export default defineComponent({
setup() {
return {
msg: ref('')
}
}
})
</script>
```
:::
2.把 test.md 文件通过路由引入
const router = [
{path: '/test', component: import('./test.md')}
]
引入路由这里只作简单的示例,动态的引入方法可以将.md文件放在一个目录下,通过遍历该目录下的.md文件生成对应路由,这里不做具体展开。
3.首先通过markdown-it先定义一下markdown的转换规则。然后编写插件index.js,截取.md文件内容进行转换,最后输出完整的html和Vue组件代码。这也是本篇文章最重要的一点。
先来定义一下markdown的规则,创建一个markdown.js文件
// plugin/markdown.js
const hljs = require('highlight.js');
const container = require('markdown-it-container')
const { stripTemplate } = require('./util')
// 定义解析成Vue组件的容器,就是解析在.md文件里 :::demo ... ::: 的内容
function mdContainer (md) {
md.use(container, 'demo', {
// 验证规则,只有匹配成功才会执行
validate(params) {
return params.trim().match(/^demo\s*(.*)$/)
},
render(tokens, idx) {
// open 节点
if (tokens[idx].nesting === 1) {
// 获取demo后面一个token的内容,如:
// ```html
// (省略...)
// ````
const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content.trim() : '';
// 组装成组件并返回
// <demo-block> 是我自己定义的一个组件,具有两个插槽source 和 code。用于展示组件效果和代码。
// <!--component-demo: ${content} :component-demo--> 就是后面用来解析成组件的内容
return `<demo-block>
<!--component-demo: ${content} :component-demo-->
<template #code>
`
} else {
// closing tag
return '</template></demo-block>';
}
}
})
}
// 定义一些配置,这里重点是highlight里面的转换
let markdown_config = {
html: true, // Enable HTML tags in source
xhtmlOut: true, // Use '/' to close single tags (<br />).
breaks: true, // Convert '\n' in paragraphs into <br>
langPrefix: 'lang-', // CSS language prefix for fenced blocks. Can be
linkify: false, // 自动识别url
typographer: true,
quotes: '“”‘’',
highlight: function (str, lang) {
// 匹配到模板类型
if (lang && hljs.getLanguage(lang)) {
try {
// 这里实际上做的是将 html的标签内容用 <template> 包裹,用于代码的展示。
let replaceStr = stripTemplate(str);
let template = `<template>\n${replaceStr}\n</template>`;
str = str.replace(replaceStr, template);
return '<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value +
'</code></pre>';
} catch (__) { }
}
return '<pre class="hljs"><code>' + markdown.utils.escapeHtml(str) + '</code></pre>';
}
}
// markdown-it对象
let markdown = require('markdown-it')(markdown_config);
markdown.use(container).use(mdContainer)
// 导出该对象
export default markdown
然后将插件index.js文件修改一下,对内容进行转换。
// plugin/index.js
import markdown from './markdown.js'
const { stripScript, stripTemplate, genInlineComponentText } = require('./util')
export default function myPlugin(options = {}) {
return {
name: 'my-plugin',
enforce: 'pre',
transform: function(code, id) {
// 截取.md文件
if (/\.md$/.test(id) === false) {
return
}
// 将code转换成html内容,这里拿到的都是经过转换后的html代码,而.md所定义的组件内容都在 <!--component-demo: ... :component-demo--> 注释里面。
const content = markdown.render(code)
// 下面只要将注释里的内容转换成Vue组件就可以了。
// 定义几个变量,用于解析内容
const startTag = '<!--component-demo:'
const startTagLen = startTag.length
const endTag = ':component-demo-->'
const endTagLen = endTag.length
let output = []; // 最后输出内容
let componentId = 0; // 组件id
let start = 0; // 字符串开始位置
let pageScript = ''; // script标签
let componentsString = ''; // 组件数据字符串
// 查找是否具有组件内容
let commentStart = content.indexOf(startTag);
let commentEnd = content.indexOf(endTag)
while(commentStart !== -1 && commentEnd !== -1) {
// 将查找到组件之前的内容先添加进去
output.push(content.slice(start, commentStart))
// <!-- *** ---> 找到之间的内容
const commentContent = content.slice(commentStart + startTagLen, commentEnd)
// 去掉标签的内容
const html = stripTemplate(commentContent)
const script = stripScript(commentContent)
// 通过vue将html模板和script标签解析成组件内容
let demoComponentContent = genInlineComponentText(html, script)
const demoComponentName = `component-demo-${componentId}`;
output.push(`<template #source><${demoComponentName} /></template>`)
componentsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`
// 重新计算下一次的位置
componentId++
start = commentEnd + endTagLen
commentStart = content.indexOf(startTag, start)
commentEnd = content.indexOf(endTag, commentStart + startTagLen)
}
// 有组件数据则注册组件,
if (componentsString) {
pageScript = `<script lang="ts">
import * as Vue from 'vue';
export default Vue.defineComponent({
name: 'component-demo',
components: {
${componentsString}
},
})
</script>`
}
// 把剩下的内容一块加进去
output.push(content.slice(start))
const html = `
<template>
${output.join('')}
</template>
${pageScript}
`;
// 把解析好的代码return出去
return {
code: html,
};
}
}
}
最后是工具类的文件util.js,这里是参考element-ui里面的文件写法。
// plugin/util.js
const { compileTemplate, TemplateCompiler } = require('@vue/compiler-sfc')
function stripScript(content) {
const result = content.match(/<(script)>([\s\S]+)<\/\1>/)
return result && result[2] ? result[2].trim() : ''
}
function stripStyle(content) {
const result = content.match(/<(style)\s*>([\s\S]+)<\/\1>/)
return result && result[2] ? result[2].trim() : ''
}
function stripTemplate(content) {
content = content.trim()
if (!content) {
return content
}
return content.replace(/<(script|style)[\s\S]+<\/\1>/g, '').trim()
}
function pad(source) {
return source
.split(/\r?\n/)
.map((line) => ` ${line}`)
.join('\n')
}
const templateReplaceRegex = /<template>([\s\S]+)<\/template>/g
function genInlineComponentText(template, script, extendsScript) {
let source = template
if (templateReplaceRegex.test(source)) {
source = source.replace(templateReplaceRegex, '$1')
}
const finalOptions = {
source: `<div>${source}</div>`,
filename: 'inline-component',
compiler: TemplateCompiler,
compilerOptions: {
mode: 'function',
},
}
// 利用Vue的底层工具将原始模板代码编译为渲染函数代码
const compiled = compileTemplate(finalOptions)
// tips
if (compiled.tips && compiled.tips.length) {
compiled.tips.forEach((tip) => {
console.warn(tip)
})
}
// errors
if (compiled.errors && compiled.errors.length) {
console.error(
`\n Error compiling template:\n${pad(compiled.source)}\n` +
compiled.errors.map((e) => ` - ${e}`).join('\n') +
'\n'
)
}
let demoComponentContent = `
${compiled.code.replace('return function render', 'function render')}
`
script = script.trim()
if (script) {
script = script
.replace(/export\s+default/, 'const democomponentExport =')
.replace(/import ({.*}) from 'vue'/g, (s, s1) => `const ${s1} = Vue`)
} else {
script = 'const democomponentExport = {}'
}
// 最后导出一个自执行的渲染函数字符串
demoComponentContent = `(function() {
${demoComponentContent}
${script}
return {
render,
...democomponentExport,
${extendsScript ? `extends: ${extendsScript},` : ''}
}
})()`
return demoComponentContent
}
module.exports = {
stripScript,
stripStyle,
stripTemplate,
genInlineComponentText,
}
完成之后来看一下Markdown转换后的效果:
总结
以上就是具体转换的实现过程,其实最重要的是要理解解析过程是怎样的一个走向,通过自定义规则达到灵活处理的效果,然后才能去拼接组件的代码,最终输出完整的组件内容。