beasties-webpack-plugin
Version:
Webpack plugin to inline critical CSS and lazy-load the rest.
181 lines (177 loc) • 6.51 kB
JavaScript
import { createRequire } from 'node:module';
import path from 'node:path';
import Beasties from 'beasties';
import { minimatch } from 'minimatch';
function tap(inst, hook, pluginName, async, callback) {
if (inst.hooks) {
const camel = hook.replace(/-([a-z])/g, (s, i) => i.toUpperCase());
inst.hooks[camel][async ? "tapAsync" : "tap"](pluginName, callback);
} else {
inst.plugin(hook, callback);
}
}
const $require = typeof require !== "undefined" ? require : createRequire(eval("import.meta.url"));
const PLUGIN_NAME = "beasties-webpack-plugin";
class BeastiesWebpackPlugin extends Beasties {
constructor(options) {
super(options);
}
/**
* Invoked by Webpack during plugin initialization
*/
apply(compiler) {
this.compiler = compiler;
this.logger = Object.assign(compiler.getInfrastructureLogger(PLUGIN_NAME), {
silent(_) {
}
});
tap(compiler, "compilation", PLUGIN_NAME, false, (compilation) => {
let htmlPluginHooks;
this.options.path = compiler.options.output.path;
this.options.publicPath = compiler.options.output.publicPath || typeof compiler.options.output.publicPath === "function" ? compilation.getAssetPath(compiler.options.output.publicPath, compilation) : compiler.options.output.publicPath;
const hasHtmlPlugin = compilation.options.plugins.some(
(p) => p?.constructor?.name === "HtmlWebpackPlugin"
);
try {
htmlPluginHooks = $require("html-webpack-plugin").getHooks(compilation);
} catch {
}
const handleHtmlPluginData = (htmlPluginData, callback) => {
this.fs = compiler.outputFileSystem;
this.compilation = compilation;
this.process(htmlPluginData.html).then((html) => {
callback(null, { ...htmlPluginData, html });
}).catch(callback);
};
if (compilation.hooks && compilation.hooks.htmlWebpackPluginAfterHtmlProcessing) {
tap(
compilation,
"html-webpack-plugin-after-html-processing",
PLUGIN_NAME,
true,
handleHtmlPluginData
);
} else if (hasHtmlPlugin && htmlPluginHooks) {
htmlPluginHooks.beforeEmit.tapAsync(PLUGIN_NAME, handleHtmlPluginData);
} else {
tap(
compilation,
"optimize-assets",
PLUGIN_NAME,
true,
(assets, callback) => {
this.fs = compiler.outputFileSystem;
this.compilation = compilation;
let htmlAssetName;
for (const name in assets) {
if (name.match(/\.html$/)) {
htmlAssetName = name;
break;
}
}
if (!htmlAssetName) {
return callback(new Error("Could not find HTML asset."));
}
const html = assets[htmlAssetName].source();
if (!html)
return callback(new Error("Empty HTML asset."));
this.process(String(html)).then((html2) => {
assets[htmlAssetName] = new compiler.webpack.sources.RawSource(html2);
callback();
}).catch(callback);
}
);
}
});
}
/**
* Given href, find the corresponding CSS asset
*/
async getCssAsset(href, style) {
const outputPath = this.options.path;
const publicPath = this.options.publicPath;
let normalizedPath = href.replace(/^\//, "");
const pathPrefix = `${(publicPath || "").replace(/(^\/|\/$)/g, "")}/`;
if (normalizedPath.indexOf(pathPrefix) === 0) {
normalizedPath = normalizedPath.substring(pathPrefix.length).replace(/^\//, "");
}
const filename = path.resolve(outputPath, normalizedPath);
const relativePath = path.relative(outputPath, filename).replace(/^\.\//, "");
const asset = this.compilation.assets[relativePath];
let sheet = asset && asset.source();
if (!sheet) {
try {
sheet = await this.readFile(filename);
this.logger.warn(
`Stylesheet "${relativePath}" not found in assets, but a file was located on disk.${this.options.pruneSource ? " This means pruneSource will not be applied." : ""}`
);
} catch {
this.logger.warn(`Unable to locate stylesheet: ${relativePath}`);
return;
}
}
style.$$asset = asset;
style.$$assetName = relativePath;
return sheet.toString();
}
/**
* Check if the stylesheet should be inlined
*/
checkInlineThreshold(link, style, sheet) {
const inlined = super.checkInlineThreshold(link, style, sheet);
if (inlined) {
const asset = style.$$asset;
if (asset) {
this.compilation.deleteAsset(style.$$assetName);
} else {
this.logger.warn(
` > ${style.$$name} was not found in assets. the resource may still be emitted but will be unreferenced.`
);
}
}
return inlined;
}
/**
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
*/
async embedAdditionalStylesheet(document) {
const styleSheetsIncluded = [];
(this.options.additionalStylesheets || []).forEach((cssFile) => {
if (styleSheetsIncluded.includes(cssFile)) {
return void 0;
}
styleSheetsIncluded.push(cssFile);
const webpackCssAssets = Object.keys(this.compilation.assets).filter(
(file) => minimatch(file, cssFile)
);
for (const asset of webpackCssAssets) {
const style = document.createElement("style");
style.$$external = true;
style.textContent = this.compilation.assets[asset].source().toString();
document.head.appendChild(style);
}
});
}
/**
* Prune the source CSS files
*/
pruneSource(style, before, sheetInverse) {
const isStyleInlined = super.pruneSource(style, before, sheetInverse);
const asset = style.$$asset;
const name = style.$$name;
if (asset) {
const minSize = this.options.minimumExternalSize;
if (minSize && sheetInverse.length < minSize) {
this.compilation.deleteAsset(style.$$assetName);
return true;
}
this.compilation.assets[style.$$assetName] = new this.compiler.webpack.sources.SourceMapSource(sheetInverse, style.$$assetName, before);
} else {
this.logger.warn(
`pruneSource is enabled, but a style (${name}) has no corresponding Webpack asset.`
);
}
return isStyleInlined;
}
}
export { BeastiesWebpackPlugin as default };