Optimize Your VitePress Blog's Architecture Design

- Lire en français
Resources: garabit

In the preceding article, we initialized our VitePress project and examined its structure. We also began incorporating our custom theme. Today, we will further progress by defining the blog's architecture and design to ensure it remains manageable and scalable over time.

The src Directory

To begin, we will relocate our project's source files to a designated src directory. This approach prevents cluttering the root directory with source files and avoids including unwanted files like README.md in our project's pages.

What constitutes our source files? Specifically, is the theme a source file? The answer is no. As we're developing a content-centric website, only markdown and public files are considered source files. Everything related to the theme is part of the VitePress configuration and will reside in the .vitepress directory.

To modify the source directory, we need to update the srcDir option in the config.mts file located in the .vitepress directory. Here is the revised content of the config.mts file:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  srcDir: 'src',

  // ...
})

This option informs VitePress where our markdown pages are stored relative to the project root. In our instance, the project root is the repository's root.

The src directory will also encompass a public directory, where static assets like images, fonts, and other unaltered files will be stored.

Note

The public directory associates with the site's root and is not processed by VitePress. Therefore, files in the public directory will be copied to the root of the generated site without any transformation.

txt
.
├── .vitepress
│   └── config.mts
├── src
│   ├── public
│   └── index.md
└── package.json

This structure is significantly cleaner and more organized. If we aim to write a blog post, we simply open the src directory, and if we desire to update our theme, we access the .vitepress directory.

The .vitepress Directory

Regarding the .vitepress directory, we can create subdirectories to aid in structuring our theme, maintaining simplicity akin to a standard Vite + Vue project.

Under .vitepress/theme, we will establish the following directories:

  • components: Contains all the Vue components used in the theme.
  • composables: Contains all the Vue composables utilized in the theme.
  • pages: Contains all the Vue components employed as pages in the Layout.vue component.
  • styles: Contains all the CSS files applied in the theme.
  • types: Contains all the TypeScript types utilized in the theme.
  • utils: Contains all the utility functions used in the theme.
txt
.
├── .vitepress
│   ├── theme
│   │   ├── components
│   │   ├── composables
│   │   ├── pages
│   │   ├── styles
│   │   ├── types
│   │   ├── utils
│   │   ├── index.ts
│   │   └── Layout.vue
│   └── config.mts
├── src
│   ├── public
│   └── index.md
└── package.json

This structure closely mirrors what many Vue developers are accustomed to, which is advantageous! It eliminates the need to learn a new project structure while utilizing VitePress. If you're familiar with structuring a Vue project, you already know how to structure a VitePress project. 👌

The pages Directory

I acknowledge this might seem confusing. I mentioned earlier that VitePress features a minimal router, and routes are generated from the src directory's structure. So, what do these pages imply?

VitePress follows the convention of using Layout.vue as the entry point for the Vue application. Given this scenario, having a layouts directory would imply that a layout utilizes a layout, which seems peculiar to me.

Renaming Layout.vue to App.vue might be an option, yet I find it less appealing. I prefer retaining the Layout.vue name as it's a VitePress convention. Moreover, as VitePress doesn't have a router, we can't use a RouterView component to render the layout and the page. This implies that Layout.vue and App.vue serve different purposes.

Ultimately, establishing a pages directory seemed the most logical decision. Within this directory, we include Vue components such as NotFound.vue, Blog/BlogIndex.vue, Blog/BlogPost.vue, etc., forming a file routing system.

Note

The pages directory is my approach to organizing the project. It's not mandated by VitePress. Any .vue files could reside in the components or layouts directory. Feel free to customize the structure according to your preferences.

In conclusion, we'll use Layout.vue to route our Markdown files to the appropriate page component. To accomplish this, we'll use the layout key in the frontmatter of our Markdown files. This key facilitates importing the correct page component in the Layout.vue file.

Consider the following Markdown file:

md
---
layout: blog-post
---

# Hello, World!

In the Layout.vue file, we can render the Blog/BlogPost.vue component as follows:

vue
<script lang="ts" setup>
import { useData } from 'vitepress'

const { frontmatter } = useData() // This is the frontmatter of the current Markdown file
</script>

<template>
  <BlogPost v-if="frontmatter.layout === 'blog-post'" />

  <Content v-else /> // Just render the content of the Markdown file
</template>

This approach enables a dynamic routing system without an actual router. Meanwhile, Layout.vue remains the entry point of the Vue application.

Note

If anything isn't clear, don't worry. We'll apply this in the upcoming articles when introducing blog posts and the blog list.

Configuring Unplugin Vue Components

In the previous code snippet, we used the BlogPost component without importing it. This magic becomes feasible with the help of Unplugin Vue Components.

Note

This step is entirely optional, and you can skip it if desired. For me, it's an excellent way to maintain clean code, and as a Nuxt user, I have a slight preference for this method. 😅

Behind the scenes, this plugin for Vite scans our files for Vue components as they pass through the Vite pipeline. When it detects a component, it adds the relevant import statement at the top of the file. This aligns with Vite's on-demand philosophy, ensuring small bundle sizes and allowing component chunking where they are utilized.

This plugin seamlessly integrates with TypeScript, ensuring that we do not compromise type checking or auto-completion in our IDE.

Installing Necessary Dependencies

First, we must install TypeScript and the Node types. This is essential as the plugin generates a declaration file containing our components' types.

bash
pnpm add -D typescript @types/node vue

Note

The installation of vue is required when utilizing pnpm due to its hoisting mechanism. Refer to the shamefully-hoist option for further explanation.

Next, a tsconfig.json file should be created at the project's root to guide TypeScript's behavior.

json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true
  },
  "include": ["./.vitepress/**/*"]
}

The @types/node is automatically incorporated by TypeScript. We include all files within the .vitepress directory where our TypeScript and Vue components reside. This pattern also imports .d.ts files, which is crucial for the plugin.

You should also add "type": "module" in the package.json file and modify the extensions of postcss.config.js and tailwind.config.js to .cjs.

Configuration

Finalizing the configuration of the plugin is straightforward:

  1. Indicate to the plugin the directory where our Vue components reside.
  2. Define to the plugin the naming conventions for our Vue components.
  3. Specify to the plugin the location for creating the declaration file.

Given the config.mts file resides within the .vitepress directory, there is more configuration than in a standard Vite project where the configuration vite.config.ts is situated at the project's root.

ts
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vitepress'

const currentDir = dirname(fileURLToPath(import.meta.url))

const componentsDir = resolve(currentDir, 'theme', 'components')
const pagesDir = resolve(currentDir, 'theme', 'pages')

export default defineConfig({
  // ...

  vite: {
    plugins: [
      Components({
        dirs: [
          componentsDir,
          pagesDir,
        ],
        include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
        dts: resolve(
          currentDir,
          'components.d.ts'
        ),
      }),
    ],
  },
})

The dirs option directs the plugin to where our components are located. In our context, we have two directories: theme/pages and theme/components, relative to the config.mts file. This distinction matters because the process initiates in the project's root, but the configuration lies within the .vitepress directory. It prevents the use of ../../ in paths.

The include option specifies the file extensions that should be scanned to add import statements. We include .vue and .md files as we might have Vue components in Markdown files without the need for explicit imports, which is a fantastic feature.

The dts option guides the plugin on where to create the declaration file. We desire to create the components.d.ts file within the .vitepress directory's root. This declaration file aids our IDE and TypeScript in providing auto-completion and type checking, even when components are not explicitly imported.

When we launch our development server, we should verify the plugin's functionality by examining the components.d.ts file. Initially, this file might not feature any components as we don't have any. Creating a temporary component will allow observation of the changes and requires a development server restart. Use r to restart the server.

Note

The current setup will not function as described, and it's normal. Continue reading to understand why and see the final configuration.

ts
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}

/* prettier-ignore */
declare module 'vue' {
  export interface GlobalComponents {
  }
}

This file is generated by the plugin, should remain unchanged manually, and should be added to the .gitignore file.

txt
node_modules
cache
components.d.ts

However, the present setup does not work due to limitations originating from Vite. Underneath, the plugin connects to the Vite files watcher, meaning it only responds to files and directories monitored by Vite. Vite, by default, watches only the root directory specified by the srcDir of the config.mts file. Consequently, files and directories within the .vitepress directory remain undetected by Vite and the plugin.

To address this, we must instruct Vite to monitor the directory where our Vue components and pages reside. This is achieved by devising a compact plugin to update Vite's watcher configuration:

ts
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitepress'

const currentDir = dirname(fileURLToPath(import.meta.url))

const componentsDir = resolve(currentDir, 'theme', 'components')
const pagesDir = resolve(currentDir, 'theme', 'pages')

export default defineConfig({
  srcDir: 'src',

  vite: {
    plugins: [
      {
        name: 'watcher',
        configureServer(server) {
          server.watcher.add([componentsDir, pagesDir])
        },
      },
      // ...
    ],
  },
})

With this adjustment, everything should function as intended. The plugin generates the declaration file, providing auto-completion and type checking within our IDE when a component is created without the needs to restart the development server.

On-Demand Everything

Can this on-demand philosophy extend further to include everything?

Absolutely, and we will achieve this using the unplugin-auto-import plugin. This plugin functions similarly to the unplugin-vue-components plugin but for JavaScript and TypeScript files.

First, we must install the plugin:

bash
pnpm add -D unplugin-auto-import

Next, configure the plugin in the config.mts file:

ts
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import AutoImport from 'unplugin-auto-import/vite'
import { defineConfig } from 'vitepress'

const currentDir = dirname(fileURLToPath(import.meta.url))

// ...

const composablesDir = resolve(currentDir, 'theme', 'composables')
const utilsDir = resolve(currentDir, 'theme', 'utils')

export default defineConfig({
  srcDir: 'src',

  vite: {
    plugins: [
      {
        name: 'watcher',
        configureServer(server) {
          // ...
          server.watcher.add([composablesDir, utilsDir])
        },
      },
      AutoImport({
        imports: ['vue', 'vitepress'],
        dirs: [composablesDir, utilsDir],
        dts: resolve(currentDir, 'auto-imports.d.ts'),
      }),
      // ...
    ],
  },
})

With this configuration, we accomplish three objectives:

  1. Automatically import all composable and utility functions from vue and vitepress, allowing ref, computed, useData, etc., to be used without explicit imports.
  2. Scan and register all composable and utility functions in the theme/composables and theme/utils directories.
  3. Enable the watcher to monitor theme/composables and theme/utils directories, similar to the unplugin-vue-components plugin.

This plugin generates an auto-imports.d.ts file for the same reasons as the components.d.ts file. It too should be added to the .gitignore file.

txt
node_modules
cache
components.d.ts
auto-imports.d.ts

Scripts

To conclude this article, let's remove the docs: prefix from our scripts in the package.json file. Since we're not creating documentation, and VitePress is the sole project in the repository, we can safely eliminate this prefix.

json
{
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview"
  }
}

Excellent! We now have a clean and organized project structure, ready to commence our blogging endeavor. In the subsequent article, we will develop the blog list and leverage one of VitePress’s core features: the data loader.

Profil Picture of Estéban

Thanks for reading! My name is Estéban, and I love to write about web development.

I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.

If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.

I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!

Continue readingImplementing Blog Index with VitePress and Tailwind CSS