This article has been put together on Github, but you can also check out the sample site.
background
Vitepress has quickly become popular in the documentation community due to the second startup speed of Vite, the powerful extension capability of Markdown-It, and the natural support for VUe3. Using VitePress to do vuE3 component library documentation has also become very popular. The author also had the honor to practice once, record here.
First of all, Vitepress’s Markdown expansion capability is undoubtedly delicious, and I find it extremely comfortable in the following aspects:
- Import Code Snippets
- Run the VUE component
Use VitePress to document the business component library, rely on Element-Plus, and write a simple DemoContainer component to wrap the Demo according to the VitePress documentation.
As time goes on and the number of components accumulates, the existing development approach gradually reveals some problems:
-
Unable to display full-screen components (height:100vh), routing components (coupling vue-Router, e.g. Menu-item)
-
Vitepress has some global styles that are annoying and often interfere with demos, such as:
table { display: block; border-collapse: collapse; margin: 1rem 0; overflow-x: auto; } Copy the code
-
Referring to Demo is cumbersome and error-prone
<script setup>Import demo1 from './demo/demo1.tsx'</script> <DemoContainer title="Basic use"> <ClientOnly> <Demo1 /> </ClientOnly> <details>
View the code <! -- This source code reference is provided by Vitepress --> <<< Packages /query-table/demo/demo1.tsx</details> </DemoContainer> Copy the code
- Other questions that are not relevant to this article…
The first two are easy to imagine iframe as the perfect solution. We tried document.createElement(‘iframe’) in DemoContainer, but it didn’t work. By the time we got the slot content, the component code was already running, it was too late to sandbox, and getting the demo source code was a problem. I thought about using a micro front end, but felt like I’d run into the same problems.
To summarize, there are two problems: cumbersome demo references and a lack of iframe schema
Dumi is probably the benchmark, but it currently only supports React.
Storybook is not the desired iframe mode, nor can it.
Vitepress-for-component forks VitePress (because VitePress does not currently support plug-ins), provides demo capabilities, but does not have iframe mode.
Elemental-plus also uses VitePress, but also without iframe mode.
I have considered giving up vitePress, but still can’t bear the markdown capability it provides. It is not impossible to copy vitePress again, but there is no need.
Since you haven’t found the perfect solution, try it yourself!
Front knowledge
For the convenience of those who have not read the vitePress source code, I briefly summarize the knowledge points related to this article.
Vitepress is essentially a Vite plugin. It’s the equivalent of vue3 + Vite SSR. It encapsulates all your logic internally, and you just write Markdown.
The ability to extend Markdown is based on markdown-it by writing a number of Markdown-it plug-ins.
Any Markdown document you write will eventually be turned into a VUE component. The principle is simple: Render a Markdown into an HTML string, and then dynamically generate a VUE component, the template content being the rendered HTML string. So it makes sense that it supports running vUE components in Markdown.
Markdown – it – demo plug-in
First of all, the demo reference is definitely a priority because it’s easier to solve and foams iframe. Element-plus and Vitepress-for-Component helped me a lot.
element-plus
Element-plus uses markdown-it-container to create a custom container that works like this:
::: demo
alert/demo.vue
:::
Copy the code
Here :: is the boundary of the container, demo is the container name, and its content is Alert /demo.vue. Element-plus goes to the specified directory to read this file and render it as a Vue component, along with its file contents, on the page.
vitepress-for-component
Vitepress-for-component takes a different approach:
<demo
src="./demo-example.vue"
language="vue"
title="Demo presentation"
desc="This is a Demo rendering example."
/>
Copy the code
The rationale is also based on the MarkDow -it plug-in:
export const demoPlugin = (md: MarkdownIt) = > {
const RE = /<demo /i;
md.renderer.rules.html_inline = (tokens, idx) = > {
const content = tokens[idx].content;
// ...
if (RE.test(content.trim())) {
// ...
const { realPath, urlPath, importMap } = md as any;
const absolutePath = path
.resolve(realPath ?? urlPath, '.. / ', src)
.split(path.sep)
.join('/');
// ...
return content.replace(
'>'.` componentName="${componentName}" htmlStr="${htmlStr}" codeStr="The ${encodeURIComponent(codeStr)}" ${
importMap ? `importMap="The ${encodeURIComponent(JSON.stringify(importMap))}"` : ' '
} >
<${componentName}></${componentName}>
`,); }else {
returncontent; }}; };Copy the code
As you can see, this is a custom HTML rendering, and if the HTML tag is demo, change it to what you want.
This idea opens up all of a sudden, you can pass in whatever you want.
But there is one detail: look at the code above, SRC is a relative path, so you need to know the path of the current MD file to determine the absolute path of SRC. Vitepress doesn’t provide this data, but since vitepress-for-Component forks Vitepress, it gets this data as naturally as it breathes:
md.urlPath = file;
Copy the code
I didn’t want to fork Vitepress due to the small number of code changes, so I adopted the patch-package scheme.
On the whole
I prefer the second syntax because it is simpler and more in line with my habits. That is:
<demo
src="./demo-example.vue"
title="Demo presentation"
desc="This is a description."
/>
Copy the code
Of course, the container is also compatible with the content of the container, which can be written as markdown:
::: demo SRC ="./demo-example.vue" title=" demo demo`Markdown`To write: : :Copy the code
According to the VitePress configuration documentation, developers can add markdown-it plug-ins. We just started copying:
Look at the code
import mdContainer from 'markdown-it-container';
import MarkdownIt from 'markdown-it';
import path from 'path';
import fs from 'fs';
import { highlight } from 'vitepress/dist/node/serve-61783397.js';
let count = 1001;
const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/;
const attrRE = /\b(?
\w+)(="(?
[^"]*)")? /g
;
const demoContainer = 'DemoContainer';
type FileDTO = {
filePath: string;
codeStr: string; htmlStr? :string;
language: string;
};
/** Read the contents of the file and return the unified format. If the file does not exist, null */ is returned
function resolveFile(absolutePath) :FileDTO | null {
// ...
const rawContent = fs.readFileSync(filePath, 'utf-8');
const language = filePath.match(/\.(\w+)$/)? .1];
return {
filePath,
codeStr: encodeURIComponent(rawContent),
language,
htmlStr: encodeURIComponent(highlight(rawContent, language)),
};
}
/** Concatenate demo information into HTML (vue template) string */
function genDemo(meta, md: MarkdownIt) {
// @ts-ignore
const { urlPath, __data: data } = md;
const hoistedTags = data.hoistedTags || (data.hoistedTags = []);
let htmlOpenString = ` <${demoContainer}`;
// <DemoContainer
let attrsStr = ' ';
// ...
const currentDir = path.dirname(urlPath);
let absolutePath = path.resolve(currentDir, meta.src);
const srcFile = resolveFile(absolutePath);
if(! srcFile) {throw new Error(`${meta.src}File does not exist);
}
// Put the information together and pass it to DemoContainer
attrsStr += ` codeStr=${srcFile.codeStr} htmlStr=${srcFile.htmlStr} language=${srcFile.language}`;
const localName = `Demo${++count}`;
/** Add component import statements to script */
addImportDeclaration(hoistedTags, localName, meta.src);
//
htmlOpenString += ` ${attrsStr}><${localName}/ > `;
return {
htmlOpenString,
};
}
// MarkdownIt.PluginSimple
export default (md: MarkdownIt) => {
md.renderer.rules.html_inline = md.renderer.rules.html_block = (tokens, idx) = > {
const content = tokens[idx].content;
if (/^
|$))/i
(?>.test(content.trim())) {
const meta = parseAttrs(content.trim());
const demoData = genDemo(meta, md);
return `${demoData.htmlOpenString}</${demoContainer}> `;
} else {
returncontent; }};// The API is designed the same way here to reuse code
md.use(mdContainer, 'demo', {
validate(params) {
return!!!!! params.trim().match(/^demo\s*(.*)$/);
},
render(tokens, idx) {
if (tokens[idx].nesting === 1 /* means the tag is opening */) {
// ::: demo follows the same line
const attrs = tokens[idx].info.trim().match(/^demo\s*(.*)$/)? .1];
const meta = parseAttrs(attrs);
const demoData = genDemo(meta, md);
return demoData.htmlOpenString + '<template #desc>';
} else {
return `</template></${demoContainer}> `; }}}); };Copy the code
The iframe mode
With the basics of markdown-it-Demo, API design parsing comes naturally:
<demo
src="./demo-example.vue"
iframe
title="Demo presentation"
desc="This is a description."
/>
Copy the code
Markdown-it-demo just concatenates a component name, gets the iframe attribute, and then concatenates an IFrame tag.
if (meta.iframe) {
htmlOpenString += ` ${attrsStr}><iframe src="/~demos/${localName}.html" />`;
} else {
// ...
addImportDeclaration(hoistedTags, localName, meta.src);
htmlOpenString += ` ${attrsStr}><${localName}/ > `;
}
Copy the code
To distinguish it from normal HTML, we agreed on /~demos/xxx.html (later changed to /-demos/xxx.html for compatibility with GH-pages) as the demo iframe address. All that remains is the iframe construction
Additional HTML builds
I guess your first thought would be to add an entry to vite. Config because Vite supports multiple HTML.
// website/vite.config.js
export default defineConfig({
// ...
build: {
rollupOptions: {
input: {
demos: resolve(__dirname, 'demos.html'),},},},},});Copy the code
But when I did it, it didn’t work: VitePress took over the route, and any access to the path was handled by the VitePress router. Even if base is set, vitePress will remind you.
DevServer intercepts all HTML requests and dynamically generates HTML based on the request path:
View code vitepress/SRC/node/plugin. The ts
const vitePressPlugin: Plugin = {
name: 'vitepress'.// ...
configureServer(server) {
if (configPath) {
server.watcher.add(configPath);
}
// serve our index.html after vite history fallback
return () = > {
server.middlewares.use((req, res, next) = > {
if(req.url! .endsWith('.html')) {
res.statusCode = 200;
res.end(` <! DOCTYPE html> <html> <head> <title></title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="description" content=""> </head> <body> <div id="app"></div> <script type="module" src="/@fs/${APP_PATH}/index.js"></script>
</body>
</html>`);
return; } next(); }); }; }};Copy the code
Dev mode
Let’s use magic to defeat magic and write a vite-plugin-demo-iframe:
Check out the code vite-plugin-demo-iframe.ts
import type { Plugin, ViteDevServer } from 'vite';
import { resolve } from 'path';
import { readFileSync } from 'fs';
import { genHtml } from './genIframe';
typeIframeMeta = { title? :string;
entry: string;
};
export default function demoIframe() :Plugin {
return {
name: 'demo-iframe-dev'.// There was no way to add entry to the plugin, there was no room for vitePress, and our ultimate goal was not just HTML, so we gave it up.
// config(rawConfig) {
// console.log('demo-iframe config', rawConfig.build.rollupOptions);
// rawConfig.build.rollupOptions.input['~demos_abc'] = '/Users/xxx/xx/abc.html';
// },
configureServer(server: ViteDevServer) {
return () = > {
server.middlewares.use(async (req, res, next) => {
// console.log('req', req.url);
// if not demo html, next it.
if(req.url? .match(/^\/~demos\/(\w+)\.html/)) {
const demoName = RegExp. $1;// console.log(' received demo iframe request ', demoName);
// I don't know how markdown-it-plugin can communicate with vite-plugin at a low cost, so I pass the plugin directly to the file. This file is generated by Markdown-it-demo.
// Due to vite, the file content is lazy, so you need to read the file each time to ensure that it is properly accessed.
const demos = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules/demos.json'), 'utf-8'));
const meta = demos[demoName];
if(! meta? .entry) { res.statusCode =404;
res.end('not found');
return;
}
meta.title = meta.title || demoName;
/** Generate HTML string, the first I write dead HTML, experiment is ok, after the modification of these code. * /
let content = genHtml(meta);
content = awaitserver.transformIndexHtml? .(req.url, content, req.originalUrl); res.end(content); }else {
awaitnext(); }}); }; }}; }Copy the code
Check out the code genifame.ts
import { resolve } from 'path';
export typeIframeMeta = { title? :string;
entry: string;
};
const autoEntry = (entry: string) = > {
if (process.env.NODE_ENV === 'development') {
return `/@fs/${entry}`;
}
return entry;
};
export const genHtml = (meta: IframeMeta) = > {
const devTip =
process.env.NODE_ENV === 'development'
? ` console. The log (' iframe mode automatically mount a vue components: % o ', the module. The default. The __file | | module. The default). Console. log(' Remove export default if you don't want to be automatically mounted '); `
: ' ';
return ` <! DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>${meta.title}</title> <style>body { margin: 0; }</style> </head> <body> <script type="module"> (async () => { const module = await import("${autoEntry(meta.entry)}");
if (module.default) {
const { createApp } = await import('vue');
const app = createApp(module.default);
const div = document.createElement('div');
${devTip}
div.setAttribute('data-comment', 'auto mount');
document.body.appendChild(div);
app.mount(div);
}
})()
</script>
</body>
</html>`;
};
Copy the code
The whole process is shown as follows:
At this point, iframe functionality for dev mode is complete, followed by build mode.
The build mode
Build mode also doesn’t work with plug-in entry, vitePress is as one-size-fits-all as it is with requests
Check out the code vite-plugin-demo-iframe.ts
import type { Plugin, ViteDevServer } from 'vite';
export default function demoIframe() :Plugin {
return {
name: 'demo-iframe'.config(rawConfig) {
console.log('demo-iframe config', rawConfig.build.rollupOptions);
rawConfig.build.rollupOptions.input['~demos_abc'] = '/Users/xxx/xx/abc.html';
},
// ...
};
}
Copy the code
Build mode, unlike Dev mode, requires a single process (think of how stupid it is to have two DevServers locally), and a single script can be strung together. In the spirit of “implement first, optimize later”, I chose to use the new Vitite.config build in order not to affect the devServer already developed and get rid of the torment of VitePress. It can be described as several arrows!
Create a build-demos. Vite. Config. ts file and dynamically add entries from the markdown-it-demo file parameters.
Check out the code build-demos. Viet.config.ts
import vue from '@vitejs/plugin-vue';
import { mergeConfig, UserConfig } from 'vite';
// Reuse configuration
import config from '.. /.. /.. /vite.config';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import type { IframeMeta } from './genIframe';
import { genHtml } from './genIframe';
// I don't know how markdown-it-plugin can communicate with vite-plugin at a low cost, so I pass the plugin directly to the file. This file is generated by Markdown-it-demo.
const demos: Record<string, IframeMeta> = JSON.parse(
readFileSync(resolve(process.cwd(), 'node_modules/demos.json'), 'utf-8'));// Virtual directory name, same as devServer
const dir = resolve(process.cwd(), '~demos');
const iframeConfig: UserConfig = {
plugins: [
// The configuration is reconfigured, but the original configuration is not written, so it is added here
vue(),
/ / todo merge
{
name: 'demo-iframe-build'.resolveId(id) {
if (id.match(/\/~demos\/(\w+)\.html/)) {
return id;
}
return undefined;
},
// Virtual file
load(id) {
if (id.match(/\/~demos\/(\w+)\.html/)) {
const demoName = RegExp. $1;const meta = demos[demoName];
if (meta) {
meta.title = meta.title || demoName;
returngenHtml(meta); }}return undefined; }},].build: {
// For convenience, the vitepress directory was selected
outDir: 'website/.vitepress/dist'.emptyOutDir: false.rollupOptions: {
input: {},},},};const input = iframeConfig.build.rollupOptions.input;
// Add all entries
Object.entries(demos).forEach(([demoName, meta]) = > {
const htmlEntry = `${dir}/${demoName}.html`;
if (Array.isArray(input)) {
input.push(htmlEntry);
} else{ input[demoName] = htmlEntry; }});export default mergeConfig(config, iframeConfig);
Copy the code
Finally string the commands together in package.json scripts:
{
"scripts": {
"build:demos": "vite build -c=website/.vitepress/markdown/plugin/build-demos.vite.config.ts"."build:docs": "vitepress build website && npm run build:demos"}}Copy the code
The overall process is shown as follows:
conclusion
Because this set is just out of the oven, so there are a lot of optimization points, issued when the right to throw a brick to attract jade.
Because VitePress has no plug-in mechanism at present, there is no abstract idea for this scheme. It is temporarily used as a sample warehouse. The warehouse address and usage method are as follows:
yarn create vitepress-demo
Copy the code
If you have any good ideas or improvements, we’ll see you in the comments section.