critters-webpack-plugin
Version:
Webpack plugin to inline critical CSS and lazy-load the rest.
265 lines (238 loc) • 8.2 kB
JavaScript
/**
* 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.
*/
import path from 'path';
import { createRequire } from 'module';
import minimatch from 'minimatch';
import sources from 'webpack-sources';
import log from 'webpack-log';
import Critters from 'critters';
import { tap } from './util';
const $require =
typeof require !== 'undefined'
? require
: createRequire(eval('import.meta.url'));
// Used to annotate this plugin's hooks in Tappable invocations
const PLUGIN_NAME = 'critters-webpack-plugin';
/** @typedef {import('critters').Options} Options */
/**
* Create a Critters plugin instance with the given options.
* @public
* @param {Options} options Options to control how Critters inlines CSS. See https://github.com/GoogleChromeLabs/critters#usage
* @example
* // webpack.config.js
* module.exports = {
* plugins: [
* new Critters({
* // Outputs: <link rel="preload" onload="this.rel='stylesheet'">
* preload: 'swap',
*
* // Don't inline critical font-face rules, but preload the font URLs:
* preloadFonts: true
* })
* ]
* }
*/
export default class CrittersWebpackPlugin extends Critters {
constructor(options) {
super(options);
// TODO: Remove webpack-log
this.logger = log({
name: 'Critters',
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) => {
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 {
var htmlPluginHooks = $require('html-webpack-plugin').getHooks(
compilation
);
} catch (err) {}
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(Error('Could not find HTML asset.'));
}
const html = assets[htmlAssetName].source();
if (!html) return callback(Error('Empty HTML asset.'));
this.process(String(html))
.then((html) => {
assets[htmlAssetName] = new sources.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.resolve(outputPath, normalizedPath);
// try to find a matching asset by filename in webpack's output (not yet written to disk)
const relativePath = path
.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(this.compilation, 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 (e) {
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;
}
styleSheetsIncluded.push(cssFile);
const webpackCssAssets = Object.keys(this.compilation.assets).filter(
(file) => minimatch(file, cssFile)
);
webpackCssAssets.map((asset) => {
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.LineToLineMappedSource(
sheetInverse,
style.$$assetName,
before
);
} else {
this.logger.warn(
'pruneSource is enabled, but a style (' +
name +
') has no corresponding Webpack asset.'
);
}
return isStyleInlined;
}
}