手写Vite插件解析Markdown文件成Vue组件

1,659 阅读4分钟

手写Vite插件解析Markdown文件成Vue组件

由于最近在开发一个基于Vite构建的Vue3的组件库,组件文档页面那一块需要使用Markdown文档来显示内容和组件的使用案例,一开始我在网上找了一些插件来去使用,后来发现找了挺多的插件都只是单纯的转换Markdown文档,而我这里是想把组件的代码也加到Markdown里面去,让Markdown文件以一个页面的形式显示,并且组件要能够正常的使用。于是干脆就自己写一个插件来解析Markdown文件。

实现思路

先看看vite官方创建插件的API,后面将通过其中的一些钩子来实现Markdown文件的转换。

image.png

首先创建一个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的具体思路:

  1. 先新建Markdown文件,并填写相关内容。
  2. 把Markdown文件通过路由引入,以页面的形式展示。
  3. 然后通过插件index.jstransform钩子截取相应的.md文件。
  4. 截取到内容后,把相应的内容通过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转换后的效果:

image.png

总结

以上就是具体转换的实现过程,其实最重要的是要理解解析过程是怎样的一个走向,通过自定义规则达到灵活处理的效果,然后才能去拼接组件的代码,最终输出完整的组件内容。