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.