vite-plugin-virtual-mpa
Version:
Out-of-box MPA plugin for Vite, with html template engine and virtual files support.
240 lines (239 loc) • 7.32 kB
JavaScript
import ejs from "ejs";
import color from "picocolors";
import fs from "node:fs";
import path from "node:path";
import history from "connect-history-api-fallback";
import { normalizePath, createFilter } from "vite";
import { minify } from "html-minifier-terser";
const name = "vite-plugin-virtual-mpa";
const bodyInject = /<\/body>/;
const pluginName = color.cyan(name);
function throwError(message) {
throw new Error(`[${pluginName}]: ${color.red(message)}`);
}
function createMpaPlugin$1(config) {
const {
template = "index.html",
verbose = true,
pages,
rewrites,
watchOptions
} = config;
let resolvedConfig;
let inputMap = {};
let virtualPageMap = {};
let tplSet = /* @__PURE__ */ new Set();
function configInit(pages2) {
const tempInputMap = {};
const tempVirtualPageMap = {};
const tempTplSet = /* @__PURE__ */ new Set([template]);
for (const page of pages2) {
const entryPath = page.filename || `${page.name}.html`;
if (entryPath.startsWith("/"))
throwError(`Make sure the path relative, received '${entryPath}'`);
if (page.name.includes("/"))
throwError(`Page name shouldn't include '/', received '${page.name}'`);
if (page.entry && !page.entry.startsWith("/")) {
throwError(
`Entry must be an absolute path relative to the project root, received '${page.entry}'`
);
}
tempInputMap[page.name] = entryPath;
tempVirtualPageMap[entryPath] = page;
page.template && tempTplSet.add(page.template);
}
inputMap = tempInputMap;
virtualPageMap = tempVirtualPageMap;
tplSet = tempTplSet;
}
function transform(fileContent, id) {
const page = virtualPageMap[id];
if (!page)
return null;
return ejs.render(
!page.entry ? fileContent : fileContent.replace(
bodyInject,
`<script type="module" src="${normalizePath(
`${page.entry}`
)}"><\/script>
</body>`
),
// Variables injection
{ ...resolvedConfig.env, ...page.data },
// For error report
{ filename: id, root: resolvedConfig.root }
);
}
return {
name: pluginName,
config() {
configInit(config.pages);
return {
appType: "mpa",
clearScreen: false,
optimizeDeps: {
entries: pages.map((v) => v.entry).filter((v) => !!v)
},
build: {
rollupOptions: {
input: inputMap
}
}
};
},
configResolved(config2) {
resolvedConfig = config2;
if (verbose) {
const colorProcess = (path2) => normalizePath(`${color.blue(`<${config2.build.outDir}>/`)}${color.green(path2)}`);
const inputFiles = Object.values(inputMap).map(colorProcess);
console.log(`[${pluginName}]: Generated virtual files:
${inputFiles.join("\n")}`);
}
},
/**
* Intercept virtual html requests.
*/
resolveId(id, importer, options) {
if (options.isEntry && virtualPageMap[id]) {
return id;
}
},
/**
* Get html according to page configurations.
*/
load(id) {
const page = virtualPageMap[id];
if (!page)
return null;
return fs.readFileSync(page.template || template, "utf-8");
},
transform,
configureServer(server) {
const {
config: config2,
watcher,
middlewares,
pluginContainer,
transformIndexHtml
} = server;
const base = normalizePath(`/${config2.base || "/"}/`);
if (watchOptions) {
const {
events,
handler,
include,
excluded
} = typeof watchOptions === "function" ? { handler: watchOptions } : watchOptions;
const isMatch = createFilter(include || /.*/, excluded);
watcher.on("all", (type, filename) => {
if (events && !events.includes(type))
return;
if (!isMatch(filename))
return;
const file = path.relative(config2.root, filename);
verbose && console.log(
`[${pluginName}]: ${color.green(`file ${type}`)} - ${color.dim(file)}`
);
handler({
type,
file,
server,
reloadPages: configInit
});
});
}
watcher.on("change", (file) => {
if (file.endsWith(".html") && tplSet.has(path.relative(config2.root, file))) {
server.ws.send({
type: "full-reload",
path: "*"
});
}
});
middlewares.use(
// @ts-ignore
history({
htmlAcceptHeaders: ["text/html", "application/xhtml+xml"],
rewrites: (rewrites || []).concat([
{
from: new RegExp(normalizePath(`/${base}/(${Object.keys(inputMap).join("|")})`)),
to: (ctx) => normalizePath(`/${inputMap[ctx.match[1]]}`)
},
{
from: /.*/,
to: (ctx) => {
const { parsedUrl: { pathname } } = ctx;
return normalizePath((pathname == null ? void 0 : pathname.endsWith(".html")) ? pathname : `${pathname}/index.html`);
}
}
])
})
);
middlewares.use(async (req, res, next) => {
const accept = req.headers.accept;
const url = req.url;
if (res.writableEnded || accept === "*/*" || !(accept == null ? void 0 : accept.includes("text/html"))) {
return next();
}
const rewritten = url.startsWith(base) ? url : normalizePath(`/${base}/${url}`);
const fileName = rewritten.replace(base, "");
if (verbose && req.originalUrl !== url) {
console.log(
`[${pluginName}]: Rewriting ${color.blue(req.originalUrl)} to ${color.blue(rewritten)}`
);
}
if (!virtualPageMap[fileName]) {
return next();
}
res.setHeader("Content-Type", "text/html");
res.statusCode = 200;
let loadResult = await pluginContainer.load(fileName);
if (!loadResult) {
throw new Error(`Failed to load url ${fileName}`);
}
loadResult = typeof loadResult === "string" ? loadResult : loadResult.code;
res.end(
await transformIndexHtml(
url,
// No transform applied, keep code as-is
transform(loadResult, fileName) ?? loadResult,
req.originalUrl
)
);
});
}
};
}
function htmlMinifyPlugin(options) {
return {
name: "vite:html-minify",
enforce: "post",
apply: "build",
transformIndexHtml: (html) => {
return minify(html, {
removeComments: true,
collapseWhitespace: true,
collapseBooleanAttributes: true,
removeEmptyAttributes: true,
minifyCSS: true,
minifyJS: true,
minifyURLs: true,
...options
});
}
};
}
function createPages(pages) {
return Array.isArray(pages) ? pages : [pages];
}
function createMpaPlugin(config) {
const { htmlMinify } = config;
return !htmlMinify ? [createMpaPlugin$1(config)] : [
createMpaPlugin$1(config),
htmlMinifyPlugin(htmlMinify === true ? {} : htmlMinify)
];
}
export {
createMpaPlugin,
createPages
};