index.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import { render } from 'ejs';
  2. import { expand } from 'dotenv-expand';
  3. import dotenv from 'dotenv';
  4. import path, { join, dirname } from 'pathe';
  5. import fse from 'fs-extra';
  6. import { normalizePath } from 'vite';
  7. import { parse } from 'node-html-parser';
  8. import fg from 'fast-glob';
  9. import consola from 'consola';
  10. import { dim } from 'colorette';
  11. import history from 'connect-history-api-fallback';
  12. import { minify } from 'html-minifier-terser';
  13. import { createFilter } from '@rollup/pluginutils';
  14. function loadEnv(mode, envDir, prefix = "") {
  15. if (mode === "local") {
  16. throw new Error(`"local" cannot be used as a mode name because it conflicts with the .local postfix for .env files.`);
  17. }
  18. const env = {};
  19. const envFiles = [
  20. `.env.${mode}.local`,
  21. `.env.${mode}`,
  22. `.env.local`,
  23. `.env`
  24. ];
  25. for (const file of envFiles) {
  26. const path = lookupFile(envDir, [file], true);
  27. if (path) {
  28. const parsed = dotenv.parse(fse.readFileSync(path));
  29. expand({
  30. parsed,
  31. ignoreProcessEnv: true
  32. });
  33. for (const [key, value] of Object.entries(parsed)) {
  34. if (key.startsWith(prefix) && env[key] === void 0) {
  35. env[key] = value;
  36. } else if (key === "NODE_ENV") {
  37. process.env.VITE_USER_NODE_ENV = value;
  38. }
  39. }
  40. }
  41. }
  42. return env;
  43. }
  44. function lookupFile(dir, formats, pathOnly = false) {
  45. for (const format of formats) {
  46. const fullPath = join(dir, format);
  47. if (fse.pathExistsSync(fullPath) && fse.statSync(fullPath).isFile()) {
  48. return pathOnly ? fullPath : fse.readFileSync(fullPath, "utf-8");
  49. }
  50. }
  51. const parentDir = dirname(dir);
  52. if (parentDir !== dir) {
  53. return lookupFile(parentDir, formats, pathOnly);
  54. }
  55. }
  56. async function isDirEmpty(dir) {
  57. return fse.readdir(dir).then((files) => {
  58. return files.length === 0;
  59. });
  60. }
  61. const DEFAULT_TEMPLATE = "index.html";
  62. const ignoreDirs = [".", "", "/"];
  63. const bodyInjectRE = /<\/body>/;
  64. function createPlugin(userOptions = {}) {
  65. const {
  66. entry,
  67. template = DEFAULT_TEMPLATE,
  68. pages = [],
  69. verbose = false
  70. } = userOptions;
  71. let viteConfig;
  72. let env = {};
  73. return {
  74. name: "vite:html",
  75. enforce: "pre",
  76. configResolved(resolvedConfig) {
  77. viteConfig = resolvedConfig;
  78. env = loadEnv(viteConfig.mode, viteConfig.root, "");
  79. },
  80. config(conf) {
  81. const input = createInput(userOptions, conf);
  82. if (input) {
  83. return {
  84. build: {
  85. rollupOptions: {
  86. input
  87. }
  88. }
  89. };
  90. }
  91. },
  92. configureServer(server) {
  93. let _pages = [];
  94. const rewrites = [];
  95. if (!isMpa(viteConfig)) {
  96. const template2 = userOptions.template || DEFAULT_TEMPLATE;
  97. const filename = DEFAULT_TEMPLATE;
  98. _pages.push({
  99. filename,
  100. template: template2
  101. });
  102. } else {
  103. _pages = pages.map((page) => {
  104. return {
  105. filename: page.filename || DEFAULT_TEMPLATE,
  106. template: page.template || DEFAULT_TEMPLATE
  107. };
  108. });
  109. }
  110. const proxy = viteConfig.server?.proxy ?? {};
  111. const baseUrl = viteConfig.base ?? "/";
  112. const keys = Object.keys(proxy);
  113. let indexPage = null;
  114. for (const page of _pages) {
  115. if (page.filename !== "index.html") {
  116. rewrites.push(createRewire(page.template, page, baseUrl, keys));
  117. } else {
  118. indexPage = page;
  119. }
  120. }
  121. if (indexPage) {
  122. rewrites.push(createRewire("", indexPage, baseUrl, keys));
  123. }
  124. server.middlewares.use(history({
  125. disableDotRule: void 0,
  126. htmlAcceptHeaders: ["text/html", "application/xhtml+xml"],
  127. rewrites
  128. }));
  129. },
  130. transformIndexHtml: {
  131. enforce: "pre",
  132. async transform(html, ctx) {
  133. const url = ctx.filename;
  134. const base = viteConfig.base;
  135. const excludeBaseUrl = url.replace(base, "/");
  136. const htmlName = path.relative(process.cwd(), excludeBaseUrl);
  137. const page = getPage(userOptions, htmlName, viteConfig);
  138. const { injectOptions = {} } = page;
  139. const _html = await renderHtml(html, {
  140. injectOptions,
  141. viteConfig,
  142. env,
  143. entry: page.entry || entry,
  144. verbose
  145. });
  146. const { tags = [] } = injectOptions;
  147. return {
  148. html: _html,
  149. tags
  150. };
  151. }
  152. },
  153. async closeBundle() {
  154. const outputDirs = [];
  155. if (isMpa(viteConfig) || pages.length) {
  156. for (const page of pages) {
  157. const dir = path.dirname(page.template);
  158. if (!ignoreDirs.includes(dir)) {
  159. outputDirs.push(dir);
  160. }
  161. }
  162. } else {
  163. const dir = path.dirname(template);
  164. if (!ignoreDirs.includes(dir)) {
  165. outputDirs.push(dir);
  166. }
  167. }
  168. const cwd = path.resolve(viteConfig.root, viteConfig.build.outDir);
  169. const htmlFiles = await fg(outputDirs.map((dir) => `${dir}/*.html`), { cwd: path.resolve(cwd), absolute: true });
  170. await Promise.all(htmlFiles.map((file) => fse.move(file, path.resolve(cwd, path.basename(file)), {
  171. overwrite: true
  172. })));
  173. const htmlDirs = await fg(outputDirs.map((dir) => dir), { cwd: path.resolve(cwd), onlyDirectories: true, absolute: true });
  174. await Promise.all(htmlDirs.map(async (item) => {
  175. const isEmpty = await isDirEmpty(item);
  176. if (isEmpty) {
  177. return fse.remove(item);
  178. }
  179. }));
  180. }
  181. };
  182. }
  183. function createInput({ pages = [], template = DEFAULT_TEMPLATE }, viteConfig) {
  184. const input = {};
  185. if (isMpa(viteConfig) || pages?.length) {
  186. const templates = pages.map((page) => page.template);
  187. templates.forEach((temp) => {
  188. let dirName = path.dirname(temp);
  189. const file = path.basename(temp);
  190. dirName = dirName.replace(/\s+/g, "").replace(/\//g, "-");
  191. const key = dirName === "." || dirName === "public" || !dirName ? file.replace(/\.html/, "") : dirName;
  192. input[key] = path.resolve(viteConfig.root, temp);
  193. });
  194. return input;
  195. } else {
  196. const dir = path.dirname(template);
  197. if (ignoreDirs.includes(dir)) {
  198. return void 0;
  199. } else {
  200. const file = path.basename(template);
  201. const key = file.replace(/\.html/, "");
  202. return {
  203. [key]: path.resolve(viteConfig.root, template)
  204. };
  205. }
  206. }
  207. }
  208. async function renderHtml(html, config) {
  209. const { injectOptions, viteConfig, env, entry, verbose } = config;
  210. const { data, ejsOptions } = injectOptions;
  211. const ejsData = {
  212. ...viteConfig?.env ?? {},
  213. ...viteConfig?.define ?? {},
  214. ...env || {},
  215. ...data
  216. };
  217. let result = await render(html, ejsData, ejsOptions);
  218. if (entry) {
  219. result = removeEntryScript(result, verbose);
  220. result = result.replace(bodyInjectRE, `<script type="module" src="${normalizePath(`${entry}`)}"><\/script>
  221. </body>`);
  222. }
  223. return result;
  224. }
  225. function getPage({ pages = [], entry, template = DEFAULT_TEMPLATE, inject = {} }, name, viteConfig) {
  226. let page;
  227. if (isMpa(viteConfig) || pages?.length) {
  228. page = getPageConfig(name, pages, DEFAULT_TEMPLATE);
  229. } else {
  230. page = createSpaPage(entry, template, inject);
  231. }
  232. return page;
  233. }
  234. function isMpa(viteConfig) {
  235. const input = viteConfig?.build?.rollupOptions?.input ?? void 0;
  236. return typeof input !== "string" && Object.keys(input || {}).length > 1;
  237. }
  238. function removeEntryScript(html, verbose = false) {
  239. if (!html) {
  240. return html;
  241. }
  242. const root = parse(html);
  243. const scriptNodes = root.querySelectorAll("script[type=module]") || [];
  244. const removedNode = [];
  245. scriptNodes.forEach((item) => {
  246. removedNode.push(item.toString());
  247. item.parentNode.removeChild(item);
  248. });
  249. verbose && removedNode.length && consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(removedNode.toString())} is deleted. You may also delete it from the index.html.
  250. `);
  251. return root.toString();
  252. }
  253. function createSpaPage(entry, template, inject = {}) {
  254. return {
  255. entry,
  256. filename: "index.html",
  257. template,
  258. injectOptions: inject
  259. };
  260. }
  261. function getPageConfig(htmlName, pages, defaultPage) {
  262. const defaultPageOption = {
  263. filename: defaultPage,
  264. template: `./${defaultPage}`
  265. };
  266. const page = pages.filter((page2) => {
  267. return path.resolve("/" + page2.template) === path.resolve("/" + htmlName);
  268. })?.[0];
  269. return page ?? defaultPageOption ?? void 0;
  270. }
  271. function createRewire(reg, page, baseUrl, proxyUrlKeys) {
  272. return {
  273. from: new RegExp(`^/${reg}*`),
  274. to({ parsedUrl }) {
  275. const pathname = parsedUrl.pathname;
  276. const excludeBaseUrl = pathname.replace(baseUrl, "/");
  277. const template = path.resolve(baseUrl, page.template);
  278. if (excludeBaseUrl === "/") {
  279. return template;
  280. }
  281. const isApiUrl = proxyUrlKeys.some((item) => pathname.startsWith(path.resolve(baseUrl, item)));
  282. return isApiUrl ? excludeBaseUrl : template;
  283. }
  284. };
  285. }
  286. const htmlFilter = createFilter(["**/*.html"]);
  287. function getOptions(minify) {
  288. return {
  289. collapseWhitespace: minify,
  290. keepClosingSlash: minify,
  291. removeComments: minify,
  292. removeRedundantAttributes: minify,
  293. removeScriptTypeAttributes: minify,
  294. removeStyleLinkTypeAttributes: minify,
  295. useShortDoctype: minify,
  296. minifyCSS: minify
  297. };
  298. }
  299. async function minifyHtml(html, minify$1) {
  300. if (typeof minify$1 === "boolean" && !minify$1) {
  301. return html;
  302. }
  303. let minifyOptions = minify$1;
  304. if (typeof minify$1 === "boolean" && minify$1) {
  305. minifyOptions = getOptions(minify$1);
  306. }
  307. return await minify(html, minifyOptions);
  308. }
  309. function createMinifyHtmlPlugin({
  310. minify = true
  311. } = {}) {
  312. return {
  313. name: "vite:minify-html",
  314. enforce: "post",
  315. async generateBundle(_, outBundle) {
  316. if (minify) {
  317. for (const bundle of Object.values(outBundle)) {
  318. if (bundle.type === "asset" && htmlFilter(bundle.fileName) && typeof bundle.source === "string") {
  319. bundle.source = await minifyHtml(bundle.source, minify);
  320. }
  321. }
  322. }
  323. }
  324. };
  325. }
  326. consola.wrapConsole();
  327. function createHtmlPlugin(userOptions = {}) {
  328. return [createPlugin(userOptions), createMinifyHtmlPlugin(userOptions)];
  329. }
  330. export { createHtmlPlugin };