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
JavaScript
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'));
}