This post is dedicated to cover the design and implementation details of vite-plugin-vue-markdown
. Note that the plugin is archived and is rewritten in unplugin
as unplugin-vue-markdown
@en@ When a Vite project is created, a vite.config.ts
file will be created to manage the configuration of the Vite project. Under the vite.config.ts
file, we lines that resemble the following code
import vue from '@vitejs/plugin-vue'
import pages from 'vite-plugin-pages'
import markdown from 'vite-plugin-vue-markdown'
import inspect from 'vite-plugin-inspect'
// other imports
export default defineConfig({
plugins: [
vue(),
pages(),
markdown({
// options here
}),
inspect(),
],
})
Because I started a Vue project with Vite, the official vue
plugin is used in the plugins
field. We can of course add more plugins into it. All the plugins will work with Vite to form a pipeline that transforms code when appropriate.
Right now, I have added vite-plugin-pages
which automatically convert files to components and create route configurations for vue-router
, markdown
which converts Markdown files into Vue components, and inspect
which allows us to see and debug the effects of each plugins in different stages.
In the following paragraphs, we will cover how vite-plugin-vue-markdown
works. Before that, let’s take a peek at the inspect page (localhost:5173/__inspect
on my machine) provided by the vite-plugin-inspect
.
---
title: test-simple
---
```typescript {1,2} filename="aaa.ts"
let a = '10'
a.trim()
```
![image](https://t4.ftcdn.net/jpg/00/97/58/97/360_F_97589769_t45CqXyzjz0KXwoBZT9PRaWGHRk5hQqQ.jpg)
<button @click="changeValue"> </button>
<script setup lang="ts">
import {ref} from 'vue';
const value = ref(1);
function changeValue(){
value.value += 1
}
</script>
The inspect page shows us three panels. The left most one show that the pipeline stages this file has passed through. For example, this test_simple.md
file has passed through 5 stages and the vite-plugin-vue-markdown
takes place in the second stage. The transform details shown on the diff panels in red and green seems to indicate that the Markdown content is rendered to HTML and then wrapped with <template>
and appended with <script setup>
tags. In the <script setup>
tags, we can see the frontmatter is exposed along with the piece of custom setup script in the original Markdown content. The plugin essentially changes the Markdown content into a Vue component. We can see that the transformed Vue component is then passed into the vue
plugin. After post-processing, it’s then delivered to the browser and rendered.
It looks clear that the goal of this plugin is transform markdown content into a vue component. This is brilliant because this preserves the reactivity that may be specified in the Markdown. For example, in the example page above, the button is actually interactive. You can see that the highlighted code very similar to what could be written in a Vue component.
In the following code snippets, Because the code will be long so I won’t specify everything at once, you might find GitHub icon on the top right helpful. The icon will take you to the GitHub repo of that file. The file names will also be specified in the top left corner to help you locate the context.
We will start from src/index.ts
. When we the plugin factory from vite-plugin-vue-markdown
by using import markdown from 'vite-plugin-vue-markdown'
, we are importing the VitePluginMarkdown
function from /src/index.ts
.
The factory has the following structure: 1. resolve configuration options; 2. create markdown renderer and transformer using the resolved options; 3. create file filter; 4. return the actual plugin object
function VitePluginMarkdown(userOptions: Options = {}): Plugin {
// resolve options (1)
const options = resolveOptions(userOptions)
// create Markdown renderer (2)
const markdownToVue = createMarkdown(options)
// create filter (3)
const filter = createFilter(
userOptions.include || /\.md$/,
userOptions.exclude,
)
return {
// the plugin detail (4)
}
}
export default VitePluginMarkdown
The plugin specifies and implements name
, enforce
, transform
, and handleHotUpdate
. The name
just serves as an identifier for Vite. What actually participate in the transformation is the rest three fields: enforce
is used to specify the order of this plugin on the Vite pipeline; transform
transforms the raw markdown content to a Vue component; handleHotUpdate
handles hot update when the dev server detects changes.
function VitePluginMarkdown(userOptions: Options = {}): Plugin {
// resolve options (1)
const options = resolveOptions(userOptions)
// create Markdown renderer (2)
const markdownToVue = createMarkdown(options)
// create filter (3)
const filter = createFilter(
userOptions.include || /\.md$/,
userOptions.exclude,
)
return {
name: 'vite-plugin-vue-markdown',
enforce: 'pre',
transform(raw, id) {
// transform details
},
async handleHotUpdate(ctx) {
// hot update server impl details
},
}
}
export default VitePluginMarkdown
enforce
: The official documents says thatenforce
can take two possible values:pre
andpost
. A plugin can specify this field to let Vite order it before or after the defaulted position which comes after Vite’s core plugins like the officialvue
plugin. That is, plugins specifypre
will be executed before core plugins, and those specifypost
will be executed after core plugins. A detailed ordering is provided below. Here, it’s crucial to specifyenforce: 'pre'
because the markdown content has to be transformed to a Vue component before passed into thevue
plugin.- Alias
- User plugins with enforce: 'pre'
- Vite core plugins
- User plugins without enforce value
- Vite build plugins
- User plugins with enforce: 'post'
- Vite post build plugins (minify, manifest, reporting)
transform
is where the transformation takes place. We can see that it’s actually very simple. First, run the current content’s id through the filter and if it did pass the filter,undefined
is returned to indicate no transformation is done. By default, we are filtering ids that end with.md
. Then, the raw content is passed to a helper function that converts the raw content into Vue component.tsreturn { //... transform(raw, id) { if (!filter(id)) return try { return markdownToVue(id, raw) } catch (e: any) { this.error(e) } }, //... }
handleHotUpdate
is similar except we read from the changed file first.
The whole transformation can be demonstrated in the following diagram:
flowchart TD input(Raw Markdownraw
and File IDid
) -- passed to --> FF VC -- is --> TR(transformed component) TR -- wrapped into --> TO(transformed output) -- piped into --> PP(next plugin) subgraph transform[Vite Markdown Plugin] direction TB FF(file filter) -- if selected --> RINPUT subgraph CM[createMarkdown
inmarkdown.ts
] MDIT(markdown-it
) MDIT -- render to --> RHTML(HTML) MDIT -- extract --> FM(frontmatter) RINPUT --> MDIT subgraph MKTV[markdownToVue
returned bycreateMarkdown
] RINPUT(raw markdown inputraw
) RHTML -- extract --> SS(setup script) RHTML -- extract --> CB(custom blocks) RHTML -- wrapped into --> template FM -- exposed in --> script SS -- bundled to --> script CB -- bundled to --> script subgraph VC[Vue Component] template(template
) script(script setup
andscript
) end end end end click input href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/index.ts#L19" _blank click FF href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/index.ts#LL11-L14C4" _blank click MDIT href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#LL45-L72" _blank click RINPUT href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L74" _blank click RHTML href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#LL83" _blank click FM href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L84" _blank click SS href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L100" _blank click CB href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L102" _blank click script href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L156-L177" _blank click template href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#L180" _blank click TR href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#LL179-L183" _blank click TO href "https://github.com/mdit-vue/vite-plugin-vue-markdown/blob/6db0486189ce9953078e73b48d19b426365e7ec9/src/markdown.ts#LL185-L188" _blank
We can find the source code for each component in /src/markdown.ts
. You can click on nodes on diagram above to jump to the specific source.
Using a similar structure, we create a simple plugin to Vite that wraps all image markdown syntax with a custom tag. For example, we can wrap the image with a magnifier vue component such that when you click the image, it brings up an overlay and enables zooming and panning features.
import type { Plugin, TransformResult } from 'vite'
import { createFilter } from 'vite'
export interface Options {
wrappingTag?: string
include?: string[]
exclude?: string[]
matchRe?: RegExp
converter?: (matched: RegExpMatchArray, tag: string) => string
}
export type ResolvedOptions = Required<Options>
export const DEFAULT_OPTIONS: ResolvedOptions = {
wrappingTag: 'Magnifier',
include: ['**/*.md'],
exclude: [],
matchRe: /!\[(.*?)\]\((.*?)\)/g,
converter(matched, tag) {
return `<${tag}>\n\n${matched[0]}\n\n</${tag}>`
},
}
function resolveOptions(options: Options = {}): ResolvedOptions {
return {
...DEFAULT_OPTIONS,
...options,
}
}
type Extractor = (code: string) => TransformResult
function getExtractor(options: ResolvedOptions): Extractor {
return code => {
const { wrappingTag, matchRe, converter } = options
const matches = code.matchAll(matchRe)
for (const match of matches)
code = code.replace(match[0], converter(match, wrappingTag))
return {
code,
map: null,
}
}
}
export default function MarkdownImageWrapper(options: Options = {}): Plugin {
const resolvedOptions = resolveOptions(options)
const extractor = getExtractor(resolvedOptions)
const filter = createFilter(resolvedOptions.include, resolvedOptions.exclude)
return {
name: 'markdown-image-wrapper',
enforce: 'pre',
transform(code, id) {
if (!filter(id)) return
return extractor(code)
},
async handleHotUpdate(ctx) {
if (!filter(ctx.file)) return
const defaultRead = ctx.read
ctx.read = async () => {
return extractor(await defaultRead()).code
}
},
}
}
Another easy thing we can do immediately is loading files as strings directly using imports. For example, the following code just loads the specified files passing the filter and provides it as strings when using import. For example, I can use import Shader from './compute_shader.wgsl?raw'
directly and use it as a string in WebGPU.
import type { Plugin } from 'vite'
import { createFilter } from 'vite'
import { dataToEsm } from '@rollup/pluginutils'
export type MinifyFactory = (content: string) => Promise<string> | string
interface Options {
include: string[]
minify: boolean | MinifyFactory
}
export type UserOptions = Partial<Options>
function defaultMinify(content: string): string {
return content
}
function resolveConfig(options: UserOptions): Options {
return {
...options,
include: ['**/*.wgsl'],
minify: true,
}
}
export default function (userOptions: UserOptions = {}): Plugin {
const options = resolveConfig(userOptions)
const minify = options.minify === true ? defaultMinify : options.minify
const filter = createFilter(options.include)
return {
name: 'vite-plugin-load-string',
async transform(source, id) {
if (!filter(id)) return
return {
code: dataToEsm(minify ? await minify(source) : source),
map: null,
}
},
}
}