Writing Inline Vue SFCs Directly in VitePress Markdown

Published on |阅读中文原文

VitePress supports Vue components inside Markdown out of the box. The one friction point is that each component has to be imported from its own file. This post covers a plugin that removes that step: write the SFC as a fenced code block in the Markdown itself, and it is compiled into a real component in place.

The problem with one-file-per-component

VitePress's Using Vue in Markdown works the way you would expect:

vue
<script setup>
import CustomComponent from '../../components/CustomComponent.vue'
</script>

Create a components/ directory, write CustomComponent.vue, import it, use it. For a component shared across many pages, that is exactly right.

But when using VitePress for a blog, a lot of components are not shared at all. They exist to demonstrate one specific point in one specific post — a button that shows an event-ordering quirk, a slider that illustrates an easing curve. Their meaning is inseparable from the paragraph above them.

Putting those in separate files has a real cost. Read the component file on its own and the context is gone; read the post and the demo is somewhere else in the tree. On a blog with an already-messy directory layout, that is a lot of orphan files whose only purpose is to be imported once.

So I wrote a fenced-block syntax that compiles an SFC in place. No file, no import.

Enabling a block

An ordinary Vue code block is just a code block:

```vue
<template>
  <button>plain code, not compiled</button>
</template>
```

To turn it into a live component, add a JSON object after the language tag with component set to true:

```vue { "component": true }
<script setup lang="ts">
const onClick = () => {
  alert('click me')
}
</script>

<template>
  <button class="bg-red-100" @click="onClick">click me</button>
</template>

<style scoped>
button {
  color: red;
}
</style>
```

The block is extracted into a virtual .vue module, compiled by Vite's normal SFC pipeline, and rendered at that position in the document. No external file is involved.

This page is built with the plugin, so the block below is live — you should see a working button:

Because it goes through the regular SFC pipeline, an inline component has the same capabilities as a file-based one: TypeScript, scoped styles, UnoCSS classes, and imports of any other component or utility module in the project.

name: reusing a block

The name field sets the compiled component's name, which lets you render it again elsewhere on the same page:

```vue { "component": true, "name": "Demo" }
your sfc code here
```

Some prose in between, then reuse it by name:

<Demo />

The name must be unique within the document, because it becomes an import identifier in the page's <script setup>.

Reusing the component defined above renders an identical node:

lazy: define now, render later

By default a component: true block renders at its own position. That is the common case: you are writing a demo where you want it.

Set lazy to true when you want to define the component without rendering it there — the block is removed from the output entirely, and you place the component yourself:

```vue { "component": true, "name": "Demo2", "lazy": true }
your sfc code here
```

Some prose in between, then render it explicitly:

<Demo2 />

The source below defines a lazy component. Nothing renders here:

It only appears once the tag is written explicitly:

Showing the source of an inline component

Displaying a live demo alongside its source is a common need, and VitePress already has import code snippets for the file-based case:

<<< @/filepath

That does not apply here, because an inline component has no file to point at. The plugin adds a parallel >>> syntax that takes a virtual module name instead:

>>> virtual:ComponentName

It is replaced by a vue code fence containing that component's source. Rendering the source of the lazy component defined above:

vue
<script setup lang="ts">
const onClick = () => {
  alert('click me')
}
</script>

<template>
  <button class="bg-red-100" @click="onClick">click me, lazy</button>
</template>

<style scoped>
button {
  color: red;
}
</style>

Note this only works for named blocks. A block without name is compiled under a generated identifier (Comp plus the MD5 of its content), which you have no practical way to reference.

Two behaviours worth knowing

Both of these are visible in the plugin's implementation and are easy to trip over.

The config object replaces the defaults, it does not merge with them. The plugin starts from { component: true, lazy: false } and then overwrites the whole object with JSON.parse of your string. So component: true must be present explicitly — a block annotated with only { "name": "Demo" } is left as an ordinary code block. If the JSON fails to parse, the plugin logs a warning and also leaves the block alone, which means a stray trailing comma degrades to "my component silently rendered as source code" rather than a build error.

Two byte-identical unnamed blocks in one page collide. The generated identifier is derived from an MD5 of the block's content, so duplicate content produces duplicate identifiers, and the injected <script setup> ends up with the same import twice — a redeclaration error at build time. Give at least one of them a name.

Also note that examples in this post are wrapped in four-backtick fences. That is not decoration: the plugin's matcher explicitly skips a vue block preceded by ````, which is what makes it possible to document the syntax without compiling it.

Installation

The plugin is published on npm, source on GitHub:

bash
npm i vite-plugin-vitepres-inline-sfc -D
# or
pnpm i vite-plugin-vitepres-inline-sfc -D

Register it as a Vite plugin in .vitepress/config.mts:

js
import { defineConfig } from 'vitepress'
import inlineSFC from 'vite-plugin-vitepres-inline-sfc'

export default defineConfig({
  // ...other vitepress config
  vite: {
    plugins: [
      inlineSFC(),
    ],
  },
})

This blog has been running it since 2024. Writing prose and its interactive demo in one file, with no separate component to keep in sync, has held up well. Issues and suggestions are welcome on the repository.

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。