UNPKG

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
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 };