const path = require('path'); const parse5 = require('parse5'); const nwmatcher = require('nwmatcher'); const css = require('css'); const prettyBytes = require('pretty-bytes'); 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.fonts] If `true`, keeps critical `@font-face` rules and preloads them. If `false`, removes the rules and does not preload the fonts * @param {Boolean} [options.preloadFonts=false] Preloads critical fonts (even those removed by `{fonts:false}`) * @param {Boolean} [options.removeFonts=false] Remove all fonts (even critical ones) * @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) { // 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) => { this.process(compiler, compilation, htmlPluginData) .then(result => { callback(null, result); }) .catch(callback); }); }); } readFile (filename, encoding) { return new Promise((resolve, reject) => { this.fs.readFile(filename, encoding, (err, data) => { if (err) reject(err); else resolve(data); }); }); } async process (compiler, compilation, htmlPluginData) { const outputPath = compiler.options.output.path; // Parse the generated HTML in a DOM we can mutate const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS); makeDomInteractive(document); // `external:false` skips processing of external sheets if (this.options.external !== false) { const externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); await Promise.all(externalSheets.map( link => this.embedLinkedStylesheet(link, compilation, outputPath) )); } // go through all the style tags in the document and reduce them to only critical CSS const styles = document.querySelectorAll('style'); await Promise.all(styles.map( style => this.processStyle(style, document) )); // serialize the document back to HTML and we're done const html = parse5.serialize(document, PARSE5_OPTS); return { html }; } /** Inline the target stylesheet referred to by a (assuming it passes `options.filter`) */ async 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(/^\.\//, '')]; // CSS loader is only injected for the first sheet, then this becomes an empty string let cssLoaderPreamble = `function $loadcss(u,l){(l=document.createElement('link')).rel='stylesheet';l.href=u;document.head.appendChild(l)}`; const media = typeof this.options.media === 'string' ? this.options.media : 'all'; // { preload:'js', media:true } // { preload:'js', media:'print' } if (this.options.media) { cssLoaderPreamble = cssLoaderPreamble.replace('l.href', "l.media='only x';l.onload=function(){l.media='" + media + "'};l.href"); } // Attempt to read from assets, falling back to a disk read const sheet = asset ? asset.source() : await this.readFile(filename, 'utf8'); // the reduced critical CSS gets injected into a new