beasties-webpack-plugin
Version:
Webpack plugin to inline critical CSS and lazy-load the rest.
227 lines (208 loc) • 8.54 kB
JavaScript
var node_module = require('node:module');
var path = require('node:path');
var Beasties = require('beasties');
var minimatch = require('minimatch');
var log = require('webpack-log');
var sources = require('webpack-sources');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
var Beasties__default = /*#__PURE__*/_interopDefaultLegacy(Beasties);
var log__default = /*#__PURE__*/_interopDefaultLegacy(log);
var sources__default = /*#__PURE__*/_interopDefaultLegacy(sources);
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);
}
}
/**
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
const $require = typeof require !== 'undefined' ? require
// TODO remove this
// eslint-disable-next-line no-eval
: node_module.createRequire(eval('import.meta.url'));
// Used to annotate this plugin's hooks in Tappable invocations
const PLUGIN_NAME = 'beasties-webpack-plugin';
/** @typedef {import('beasties').Options} Options */
/**
* Create a Beasties plugin instance with the given options.
* @public
* @param {Options} options Options to control how Beasties inlines CSS. See https://github.com/danielroe/beasties#usage
* @example
* // webpack.config.js
* module.exports = {
* plugins: [
* new Beasties({
* // Outputs: <link rel="preload" onload="this.rel='stylesheet'">
* preload: 'swap',
*
* // Don't inline critical font-face rules, but preload the font URLs:
* preloadFonts: true
* })
* ]
* }
*/
class BeastiesWebpackPlugin extends Beasties__default["default"] {
constructor(options) {
super(options);
// TODO: Remove webpack-log
this.logger = log__default["default"]({
name: 'Beasties',
unique: true,
level: this.options.logLevel
});
}
/**
* Invoked by Webpack during plugin initialization
*/
apply(compiler) {
// hook into the compiler to get a Compilation instance...
tap(compiler, 'compilation', PLUGIN_NAME, false, compilation => {
let htmlPluginHooks;
this.options.path = compiler.options.output.path;
this.options.publicPath = compiler.options.output.publicPath;
const hasHtmlPlugin = compilation.options.plugins.find(p => p.constructor && p.constructor.name === 'HtmlWebpackPlugin');
try {
htmlPluginHooks = $require('html-webpack-plugin').getHooks(compilation);
} catch {}
const handleHtmlPluginData = (htmlPluginData, callback) => {
this.fs = compilation.outputFileSystem;
this.compilation = compilation;
this.process(htmlPluginData.html).then(html => {
callback(null, {
html
});
}).catch(callback);
};
// get an "after" hook into html-webpack-plugin's HTML generation.
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 {
// If html-webpack-plugin isn't used, process the first HTML asset as an optimize step
tap(compilation, 'optimize-assets', PLUGIN_NAME, true, (assets, callback) => {
this.fs = compilation.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(html => {
assets[htmlAssetName] = new sources__default["default"].RawSource(html);
callback();
}).catch(callback);
});
}
});
}
/**
* Given href, find the corresponding CSS asset
*/
async getCssAsset(href, style) {
const outputPath = this.options.path;
const publicPath = this.options.publicPath;
// CHECK - the output path
// path on disk (with output.publicPath removed)
let normalizedPath = href.replace(/^\//, '');
const pathPrefix = `${(publicPath || '').replace(/(^\/|\/$)/g, '')}/`;
if (normalizedPath.indexOf(pathPrefix) === 0) {
normalizedPath = normalizedPath.substring(pathPrefix.length).replace(/^\//, '');
}
const filename = path__default["default"].resolve(outputPath, normalizedPath);
// try to find a matching asset by filename in webpack's output (not yet written to disk)
const relativePath = path__default["default"].relative(outputPath, filename).replace(/^\.\//, '');
const asset = this.compilation.assets[relativePath]; // compilation.assets[relativePath];
// Attempt to read from assets, falling back to a disk read
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;
// style.$$assets = this.compilation.assets;
return sheet;
}
checkInlineThreshold(link, style, sheet) {
const inlined = super.checkInlineThreshold(link, style, sheet);
if (inlined) {
const asset = style.$$asset;
if (asset) {
delete this.compilation.assets[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 undefined;
}
styleSheetsIncluded.push(cssFile);
const webpackCssAssets = Object.keys(this.compilation.assets).filter(file => minimatch.minimatch(file, cssFile));
for (const asset of webpackCssAssets) {
const style = document.createElement('style');
style.$$external = true;
style.textContent = this.compilation.assets[asset].source();
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) {
// if external stylesheet would be below minimum size, just inline everything
const minSize = this.options.minimumExternalSize;
if (minSize && sheetInverse.length < minSize) {
// delete the webpack asset:
delete this.compilation.assets[style.$$assetName];
return true;
}
this.compilation.assets[style.$$assetName] = new sources__default["default"].SourceMapSource(sheetInverse, style.$$assetName, before);
} else {
this.logger.warn(`pruneSource is enabled, but a style (${name}) has no corresponding Webpack asset.`);
}
return isStyleInlined;
}
}
module.exports = BeastiesWebpackPlugin;