UNPKG

vite-plugin-virtual-mpa

Version:

Out-of-box MPA plugin for Vite, with html template engine and virtual files support.

349 lines (343 loc) 10.7 kB
// src/plugin.ts import ejs from "ejs"; import color from "picocolors"; import fs2 from "fs"; import path2 from "path"; import history from "connect-history-api-fallback"; // package.json var name = "vite-plugin-virtual-mpa"; // src/utils.ts import path from "path"; import fs from "fs"; function replaceSlash(str) { return str?.replaceAll(/[\\/]+/g, "/"); } function createPages(pages) { return Array.isArray(pages) ? pages : [pages]; } function scanPages(scanOptions) { const { filename, entryFile, scanDirs, template } = scanOptions || {}; const pages = []; for (const entryDir of [scanDirs].flat().filter(Boolean)) { for (const name2 of fs.readdirSync(entryDir)) { const dir = path.join(entryDir, name2); if (!fs.statSync(dir).isDirectory()) continue; const entryPath = entryFile ? path.join(dir, entryFile) : ""; const tplPath = template ? path.join(dir, template) : ""; pages.push({ name: name2, template: replaceSlash( fs.existsSync(tplPath) ? tplPath : void 0 ), entry: replaceSlash( fs.existsSync(entryPath) ? path.join("/", entryPath) : void 0 ), filename: replaceSlash( typeof filename === "function" ? filename(name2) : void 0 ) }); } } return pages; } function resolvePageById(id, root, pageMap) { return pageMap[replaceSlash(path.relative(root, id))]; } // src/plugin.ts import { normalizePath, createFilter } from "vite"; var PREFIX = "\0virtual-page:"; var bodyInject = /<\/body>/; var pluginName = color.cyan(name); function createMpaPlugin(config) { const { template = "index.html", verbose = true, pages = [], rewrites, previewRewrites, watchOptions, scanOptions, transformHtml } = 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, ...scanPages(scanOptions)]) { const { name: name2, filename, template: template2, entry } = page; for (const item of [name2, filename, template2, entry]) { if (item && item.includes("\\")) { throwError( `'\\' is not allowed, please use '/' instead, received ${item}` ); } } const virtualFilename = filename || `${name2}.html`; if (virtualFilename.startsWith("/")) throwError( `Make sure the path relative, received '${virtualFilename}'` ); if (name2.includes("/")) throwError(`Page name shouldn't include '/', received '${name2}'`); if (entry && !entry.startsWith("/")) { throwError( `Entry must be an absolute path relative to the project root, received '${entry}'` ); } if (tempInputMap[name2]) continue; tempInputMap[name2] = virtualFilename; tempVirtualPageMap[virtualFilename] = page; template2 && tempTplSet.add(template2); } inputMap = tempInputMap; virtualPageMap = tempVirtualPageMap; tplSet = tempTplSet; } function useHistoryFallbackMiddleware(middlewares, rewrites2 = []) { const { base } = resolvedConfig; if (rewrites2 === false) return; middlewares.use( // @ts-ignore history({ // Override the index (default /index.html). index: normalizePath(`/${base}/index.html`), htmlAcceptHeaders: ["text/html", "application/xhtml+xml"], rewrites: rewrites2.concat([ { /** * Put built-in matching rules in order of length so that to preferentially match longer paths. * Closed #52. */ from: new RegExp( normalizePath( `/${base}/(${Object.keys(inputMap).sort((a, b) => b.length - a.length).join("|")})` ) ), to: (ctx) => { return normalizePath(`/${base}/${inputMap[ctx.match[1]]}`); } }, { from: /\/$/, /** * Support /dir/ without explicit index.html * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L13 */ to({ parsedUrl, request }) { const rewritten = decodeURIComponent(parsedUrl.pathname) + "index.html"; if (fs2.existsSync(rewritten.replace(base, ""))) { return rewritten; } return request.url; } } ]) }) ); if (verbose) { middlewares.use((req, res, next) => { const { url, originalUrl } = req; if (originalUrl !== url) { console.log( `[${pluginName}]: Rewriting ${color.blue(originalUrl)} to ${color.blue(url)}` ); } next(); }); } } return { name: pluginName, config(config2) { configInit(pages); return { appType: "mpa", clearScreen: config2.clearScreen ?? false, optimizeDeps: { entries: pages.map((v) => v.entry).filter((v) => !!v) }, build: { rollupOptions: { input: Object.values(inputMap).map((v) => PREFIX + v) // Use PREFIX to distinguish these files from others. } } }; }, configResolved(config2) { resolvedConfig = config2; if (verbose) { const colorProcess = (path3) => normalizePath( `${color.blue(`<${config2.build.outDir}>/`)}${color.green(path3)}` ); const inputFiles = Object.values(inputMap).map(colorProcess); console.log( `[${pluginName}]: Generated virtual files: ${inputFiles.join("\n")}` ); } }, /** * Intercept virtual html requests. */ resolveId(id) { return id.startsWith(PREFIX) ? ( /** * Entry paths here must be absolute, otherwise it may cause problem on Windows. Closes #43 * @see https://github.com/vitejs/vite/issues/9771 */ path2.resolve(resolvedConfig.root, id.slice(PREFIX.length)) ) : void 0; }, /** * Get html according to page configurations. */ load(id) { const page = resolvePageById(id, resolvedConfig.root, virtualPageMap); if (!page) return null; const templateContent = fs2.readFileSync( page.template || template, "utf-8" ); return ejs.render( !page.entry ? templateContent : templateContent.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 } ); }, transformIndexHtml(html, ctx) { const page = resolvePageById( ctx.filename, resolvedConfig.root, virtualPageMap ); return page && transformHtml?.(html, { ...ctx, page }); }, 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 = replaceSlash(path2.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(replaceSlash(path2.relative(config2.root, file)))) { (server.ws || server.hot).send({ type: "full-reload", path: "*" }); } }); useHistoryFallbackMiddleware(middlewares, rewrites); middlewares.use(async (req, res, next) => { const url = req.url; const fileName = url.replace(base, "").replace(/[?#].*$/s, ""); if (res.writableEnded || !fileName.endsWith(".html") || // HTML Fallback Middleware appends '.html' to URLs !virtualPageMap[fileName]) { return next(); } Object.entries(config2?.server?.headers || {}).forEach( ([key, value]) => { res.setHeader(key, value); } ); res.setHeader("Content-Type", "text/html"); res.statusCode = 200; try { const loadResult = await pluginContainer.load( path2.resolve(config2.root, fileName) ); if (!loadResult) { return next(new Error(`Failed to load url ${fileName}`)); } res.end( await transformIndexHtml( url, typeof loadResult === "string" ? loadResult : loadResult.code, req.originalUrl ) ); } catch (e) { next(e); } }); }, configurePreviewServer(server) { useHistoryFallbackMiddleware(server.middlewares, previewRewrites); } }; } function throwError(message) { throw new Error(`[${pluginName}]: ${color.red(message)}`); } // src/html-minify.ts import { minify } from "html-minifier-terser"; 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 }); } }; } // src/index.ts function createMpaPlugin2(config) { const { htmlMinify } = config; return !htmlMinify ? [createMpaPlugin(config)] : [ createMpaPlugin(config), htmlMinifyPlugin(htmlMinify === true ? {} : htmlMinify) ]; } export { createMpaPlugin2 as createMpaPlugin, createPages };