UNPKG

vite-plugin-singlefile-compression

Version:

Compress all assets and embeds them into `dist/index.html`, making it convenient to share as a single HTML file.

243 lines (242 loc) 10.4 kB
import pc from "picocolors"; import { minify as htmlMinify } from 'html-minifier-terser'; import { JSDOM } from 'jsdom'; import path from 'path'; import fs from 'fs'; import { pathToFileURL } from "url"; import { version } from './getVersion.js'; import { template } from './getTemplate.js'; import { bufferToDataURL } from "./dataurl.js"; import { KiB } from "./KiB.js"; import { getInnerOptions } from "./options.js"; import { cutPrefix } from "./cutPrefix.js"; export function singleFileCompression(opt) { let conf; return { name: "singleFileCompression", enforce: "post", config: setConfig, configResolved(c) { conf = c; }, generateBundle: (_, bundle) => generateBundle(bundle, conf, getInnerOptions(opt)), }; } export default singleFileCompression; function setConfig(config) { config.base ??= './'; config.build ??= {}; config.build.cssCodeSplit ??= false; config.build.assetsInlineLimit ??= () => true; config.build.chunkSizeWarningLimit ??= Number.MAX_SAFE_INTEGER; config.build.modulePreload ??= { polyfill: false }; config.build.reportCompressedSize ??= false; config.build.rollupOptions ??= {}; for (const output of [config.build.rollupOptions.output ??= {}].flat(1)) { output.inlineDynamicImports ??= true; } } async function generateBundle(bundle, config, options) { console.log(pc.reset('\n\n') + pc.cyan('vite-plugin-singlefile-compression ' + version) + pc.green(' building...')); // rename if (options.rename && options.rename !== "index.html" && ("index.html" in bundle) && !(options.rename in bundle)) { bundle[options.rename] = bundle["index.html"]; bundle[options.rename].fileName = options.rename; delete bundle["index.html"]; } const distURL = pathToFileURL(config.build.outDir).href + '/', /** "assets/" */ assetsDir = path.posix.join(config.build.assetsDir, '/'), /** "./assets/" */ assetsDirWithBase = config.base + assetsDir, /** '[href^="./assets/"]' */ assetsHrefSelector = `[href^="${assetsDirWithBase}"]`, /** '[src^="./assets/"]' */ assetsSrcSelector = `[src^="${assetsDirWithBase}"]`, fakeScript = `_vitePluginSinglefileCompression(${Date.now()})`, globalDelete = new Set(), globalDoNotDelete = new Set(), globalRemoveDistFileNames = new Set(), globalAssetsDataURL = {}, globalPublicFilesCache = {}, /** format: ["assets/index-XXXXXXXX.js"] */ bundleAssetsNames = [], /** format: ["index.html"] */ bundleHTMLNames = []; for (const name of Object.keys(bundle)) { if (name.startsWith(assetsDir)) bundleAssetsNames.push(name); else if (name.endsWith('.html')) bundleHTMLNames.push(name); } for (const htmlFileName of bundleHTMLNames) { console.log("\n " + pc.underline(pc.cyan(distURL) + pc.greenBright(bundle[htmlFileName].fileName))); // init const htmlChunk = bundle[htmlFileName], oldHTML = htmlChunk.source, dom = new JSDOM(oldHTML), document = dom.window.document, thisDel = new Set(), newJSCode = [], scriptElement = document.querySelector(`script[type=module]${assetsSrcSelector}`), scriptName = scriptElement ? cutPrefix(scriptElement.src, config.base) : ''; let oldSize = oldHTML.length; // fill fake script if (scriptElement) { scriptElement.remove(); scriptElement.removeAttribute('src'); scriptElement.innerHTML = fakeScript; document.body.appendChild(scriptElement); } else { document.body.insertAdjacentHTML('beforeend', `<script type="module">${fakeScript}</script>`); } // get css tag let allCSS = ''; for (const element of document.querySelectorAll(`link[rel=stylesheet]${assetsHrefSelector}`)) { const name = cutPrefix(element.href, config.base); thisDel.add(name); const css = bundle[name]; const cssSource = css.source; if (cssSource) { oldSize += cssSource.length; // do not delete not inlined asset for (const name of bundleAssetsNames) { if (cssSource.includes(name.slice(assetsDir.length))) globalDoNotDelete.add(name); } // add script for load css allCSS += cssSource.replace(/\n$/, ''); } // remove tag element.remove(); } newJSCode.push(template.css(allCSS)); // inline html assets const assetsDataURL = {}; if (options.tryInlineHtmlAssets) { for (const element of document.querySelectorAll(assetsSrcSelector)) { const name = cutPrefix(element.src, assetsDirWithBase); if (name.endsWith('.js')) continue; if (!Object.hasOwn(assetsDataURL, name)) { const bundleName = assetsDir + name; const a = bundle[bundleName]; if (!a) continue; thisDel.add(bundleName); oldSize += a.source.length; if (!Object.hasOwn(globalAssetsDataURL, name)) globalAssetsDataURL[name] = bufferToDataURL(name, Buffer.from( //@ts-ignore a.source)); assetsDataURL[name] = globalAssetsDataURL[name]; } element.src = `data:${name}`; } } // inline html icon if (options.tryInlineHtmlPublicIcon) { let needInline = true; let iconName = 'favicon.ico'; // replace tag const element = document.querySelector(`link[rel=icon][href^="${config.base}"], link[rel="shortcut icon"][href^="${config.base}"]`); if (element) { iconName = cutPrefix(element.href, config.base); if (bundleAssetsNames.includes(iconName)) { needInline = false; } else { element.rel = 'icon'; element.href = 'data:'; } } if (needInline) { // inline try { if (!Object.hasOwn(globalPublicFilesCache, iconName)) { // dist/favicon.ico let Path = path.join(config.build.outDir, iconName); if (fs.existsSync(Path)) { globalRemoveDistFileNames.add(iconName); } else { // public/favicon.ico Path = path.join(config.publicDir, iconName); } // read const b = fs.readFileSync(Path); globalPublicFilesCache[iconName] = { dataURL: bufferToDataURL(iconName, b), size: b.length }; } const { dataURL, size } = globalPublicFilesCache[iconName]; oldSize += size; newJSCode.push(template.icon(dataURL)); if (!element) { // add link icon tag document.head.insertAdjacentHTML('beforeend', '<link rel="icon" href="data:">'); } } catch (e) { if (element) console.error(e); } } } // generate html htmlChunk.source = dom.serialize(); // minify html if (options.htmlMinifierTerser) htmlChunk.source = await htmlMinify(htmlChunk.source, options.htmlMinifierTerser); // fill script function inlineHtmlAssets() { if (options.tryInlineHtmlAssets) { // add script for load html assets const assetsJSON = JSON.stringify(assetsDataURL); if (assetsJSON !== '{}') newJSCode.push(template.assets(assetsJSON)); } } if (scriptElement) { thisDel.add(scriptName); let { code } = bundle[scriptName]; oldSize += code.length; code = code.replace(/;?\n?$/, ''); // do not delete not inlined asset for (const name of bundleAssetsNames) { const assetName = name.slice(assetsDir.length); if (code.includes(assetName)) { globalDoNotDelete.add(name); delete assetsDataURL[assetName]; } } inlineHtmlAssets(); newJSCode.push(template.importmeta(scriptName)); // 此 polyfill 仅在以下选项的值为 true 时需要。 // config.build.rollupOptions.output.inlineDynamicImports if (code.includes("__VITE_PRELOAD__")) newJSCode.push("var __VITE_PRELOAD__"); newJSCode.push(code); } else { inlineHtmlAssets(); } htmlChunk.source = htmlChunk.source.split(fakeScript, 2).join(template.base(newJSCode.join(';'), options.compressFormat, options.useBase128)); // log console.log(" " + pc.gray(KiB(oldSize) + " -> ") + pc.cyanBright(KiB(htmlChunk.source.length)) + '\n'); // delete assets for (const name of thisDel) { globalDelete.add(name); } } if (options.removeInlinedAssetFiles) { // delete inlined assets for (const name of globalDelete) { // do not delete not inlined asset if (!globalDoNotDelete.has(name)) delete bundle[name]; } } if (options.removeInlinedPublicIconFiles) { // delete inlined public files for (const name of globalRemoveDistFileNames) { try { fs.unlinkSync(path.join(config.build.outDir, name)); } catch (e) { console.error(e); } } } console.log(pc.green('Finish.') + pc.reset('\n')); }