UNPKG

beasties-webpack-plugin

Version:

Webpack plugin to inline critical CSS and lazy-load the rest.

181 lines (177 loc) 6.51 kB
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 };