Removing Boilerplate Code from a Vite/Vue.js Multipage Website
I started updating a Vue.js 2/Vue CLI multi-page website to Vue.js 3/Vite and decided it was time to solve a problem that had been bugging me for a while. My website is a truly multi-page app in the sense there are a couple of dozen Vue.js application roots, each hosted on an ASP.NET web page¹.
The Problem: Duplicate Code Allergy
The problem is that if I follow the general guidelines from the Vite.js team, every time I add a new page/App, I have to touch the vite.config file and clone my index.html and main.ts file. Then, I end up with a bunch of boilerplate code that is duplicated for every page. I have an allergy to duplicate code that significantly predates Hunt and Thomas’s formalization of the DRY principle², so I’d like to eliminate it with prejudice.
I spent most of my effort in my original Vue2/WebPack project on the ASP.NET end, reducing the duplication on the hosting page to effectively nothing, and this also removed the need for having an index.html for each page. In addition, I partly solved the boilerplate code problem by factoring out as much of the code into a helper function as I could. But this didn’t solve the fundamental problem of having to add a config entry and a main.ts for every app/page, even if the boilerplate code in main.ts was as small as I could get it.
Using a plugin to infer Rollup.js input configuration
I decided to dive into Vite.js/Rollup.js configuration and plugin architecture to see where it would take me. I started by looking for existing plugins that would solve the problem for me. A couple³come close, but they don’t quite fit the bill. I believe they were intended for sites where the Vite.js endpoints are still HTML pages. The vite-plugin-virtual-map plugin got pretty close to what I wanted but is aimed at “virtualizing” the *.html file root, which I already managed with ASP.NET.
That left me with hand-rolling my own inline plugin. Creating this plugin turned out to be easier than expected. I used the vite-plugin-virtual-mpa project as a starting point.
Here’s what I did:
I started with a vanilla vite/vue.js app. I used yarn create vite
and then chose Vue and TypeScript at the prompts.
The files that we’re concerned with here are index.html
and main.ts
(and we’ll look at vite.config.ts shortly)
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
main.ts (keep in mind that this is a demo, the file in my full website is considerably larger)
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
I then refactored to my multi-page scheme, which involved creating a pages directory under my src directory and then creating a directory under that for each page:
src/
├─ pages/
│ ├─ home/
│ │ ├─ App.vue
│ │ ├─ index.html
│ │ ├─ main.ts
│ ├─ about/
│ │ ├─ App.vue
│ │ ├─ index.html
│ │ ├─ main.ts
│ ├─ credits/
│ │ ├─ App.vue
│ │ ├─ index.html
│ │ ├─ main.ts
In the build section of vite.config.ts, I have rollupOptions:
rollupOptions: {
input: {
home: resolve(__dirname, 'src/pages/main/index.html'),
about: resolve(__dirname, 'src/pages/main/about.html'),
credits: resolve(__dirname, 'src/pages/main/credits.html'),
}
}
All of the unique code is in App.vue
index.html
and main.ts
are exactly the same for each page. Well, not quite. Index.html
has a hard-coded path to main.ts
That is different for each page, but since I’m generating the HTML with ASP.NET, I’ll cover that detail in a future post.
The goal is to delete all of the index.html
and main.ts
files and get Vite to infer them from the locations of the App.vue
files that remain so that all I have to do is add a new directory with an App.vue
file to create a new page. I won’t even have to touch vite.config.ts
The plugin (Initial Version)
I started by creating a plugin to infer the rollup inputs from the directory structure. Since I still had maint.ts
files, I used those for the inference. Vite has the option of letting a plugin modify the configuration before it’s resolved, so I used this to find the files that matched my pattern src/pages/*/maint.ts
, used the directory name for the name of the input, and built the rollup input options from that:
function AutoEndpoints() {
return {
name: 'auto-endpoints',
config(): UserConfig {
const root = 'src/pages/'
const pattern = root + '*/main.ts'
const length = root.length
const dirs = fg.globSync(pattern).map((p) => dirname(p).substring(length))
console.log(dirs.join(','))
const input = dirs.reduce((obj, item) => {
const value = resolve(__dirname, root + item + '/main.ts')
obj[item] = value
return obj
}, {} as any)
return {
build: {
rollupOptions: {
input: input
}
}
}
}
}
The entire file is available on GitHub.
The plugin (Final Version)
I then tackled the slightly more complex problem of getting the system to generate all of the maint.ts
files for me. Since they were identical, I started by embedding the file in my code. The file is only five lines; I’ll factor out a separate template file if it gets bigger. I implemented the Rollup.js load hook to pass back my “template” as a string. This almost worked, but since the file was no longer on the file system, Rollup didn’t know how to resolve the relative path where App.vue
was located. Contrary to my earlier claim, that is how the main.ts
files are actually different; they are located in different places.
I had to dig a little deeper. I used Rollup’s ResolveId hook to let it know that I would handle ids that end in “main.ts”. With that change, I started getting the full path of my “main” files as the id
in my load hook. This let me do a little string manipulation to transform the main.ts
path into the App.vue
path and replace that in my main.ts template. My embedded string is now a real template with very simple syntax⁴. The syntax is that the template is a string that includes a substring “{App}”, which will be replaced with the full path to App.vue :-)
function AutoEndpoints(): Plugin {
const main = `import 'vite/modulepreload-polyfill'
import '@/assets/main.css'
import { createApp } from 'vue'
import App from '{app}'
createApp(App).mount('#app')
`;
return {
name: 'auto-endpoints',
async config(): Promise<UserConfig> {
const root = 'src/pages/';
const pattern = root + '*/App.vue';
const length = root.length;
const dirs = (await fg.glob(pattern)).map((p) => dirname(p).substring(length));
console.log(dirs.join(','));
const input = dirs.reduce((obj, item) => {
const value = resolve(__dirname, root + item + '/main.ts');
obj[item] = value;
return obj;
}, {} as any);
return {
build: {
rollupOptions: {
input: input,
},
},
};
},
resolveId(id: string): null | string {
return id.endsWith('main.ts') ? id : null;
},
load(id: string): null | string {
if (!id.endsWith('main.ts')) return null;
id = normalizePath(id);
const app = id.replace(/.*\/src\//, '@/').replace('main.ts', 'App.vue');
return main.replace('{app}', app);
},
};
}
The entire file is available on GitHub.
Now everything is working. The full project is up on GitHub. I may end up extending the template structure further. My complete website has some pages that don’t need all the same dependencies as others. If I go down that path, I’ll post again.
I plan to do at least one other article in this series, showing how I blend ASP.NET and Vite/Vue in my multi-page application. If you can’t wait, I have a fully functional example of ASP.NET Core, Vite, and Vue working together as a multi-page on GitHub. As always, if this is helpful, please let me know. And let me know if you are struggling with a variation of this problem.
¹ This is distinct from a Vue.js multi-page website with a single vue.js app managed by a vue.js router.
² I reread The Pragmatic Programmer recently and would highly recommend reading through the second edition, even if you read the first edition. They have done a great job of updating the most important parts.
³ vite-plugin-mpa and vite-plugin-virtual-mpa
⁴ If you’re not using ASP.NET to generate your index page, you should be able to use this technique to fix the reference to main.ts in index.html.