const fs = require('fs'); const { promisify } = require('util'); const path = require('path'); const parse5 = require('parse5'); const nwmatcher = require('nwmatcher'); const css = require('css'); const prettyBytes = require('pretty-bytes'); const readFile = promisify(fs.readFile); const treeAdapter = parse5.treeAdapters.htmlparser2; const PLUGIN_NAME = 'critters-webpack-plugin'; const PARSE5_OPTS = { treeAdapter }; /** Critters: Webpack Plugin Edition! * @class * @param {Object} options * @param {Boolean} [options.external=true] Fetch and inline critical styles from external stylesheets * @param {Boolean} [options.async=false] Convert critical-inlined external stylesheets to load asynchronously (via link rel="preload" - see https://filamentgroup.com/lab/async-css.html) * @param {Boolean} [options.preload=false] (requires `async` option) Append a new into
instead of swapping the preload's rel attribute * @param {Boolean} [options.compress=true] Compress resulting critical CSS */ module.exports = class CrittersWebpackPlugin { constructor (options) { this.options = options || {}; this.urlFilter = this.options.filter; if (this.urlFilter instanceof RegExp) { this.urlFilter = this.urlFilter.test.bind(this.urlFilter); } } /** Invoked by Webpack during plugin initialization */ apply (compiler) { const outputPath = compiler.options.output.path; // hook into the compiler to get a Compilation instance... compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => { // ... which is how we get an "after" hook into html-webpack-plugin's HTML generation. compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => { // Parse the generated HTML in a DOM we can mutate const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS); makeDomInteractive(document); let externalStylesProcessed = Promise.resolve(); // `external:false` skips processing of external sheets if (this.options.external !== false) { const externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); externalStylesProcessed = Promise.all(externalSheets.map( link => this.embedLinkedStylesheet(link, compilation, outputPath) )); } externalStylesProcessed .then(() => { // go through all the style tags in the document and reduce them to only critical CSS const styles = document.querySelectorAll('style'); return Promise.all(styles.map(style => this.processStyle(style, document))); }) .then(() => { // serialize the document back to HTML and we're done const html = parse5.serialize(document, PARSE5_OPTS); callback(null, { html }); }) .catch(callback); }); }); } /** Inline the target stylesheet referred to by a (assuming it passes `options.filter`) */ embedLinkedStylesheet (link, compilation, outputPath) { const href = link.getAttribute('href'); const document = link.ownerDocument; // skip filtered resources, or network resources if no filter is provided if (this.urlFilter ? this.urlFilter(href) : href.match(/^(https?:)?\/\//)) return Promise.resolve(); // path on disk const filename = path.resolve(outputPath, href.replace(/^\//, '')); // try to find a matching asset by filename in webpack's output (not yet written to disk) const asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')]; // wait for a disk read if we had to go to disk const promise = asset ? Promise.resolve(asset.source()) : readFile(filename, 'utf8'); return promise.then(sheet => { // the reduced critical CSS gets injected into a new