diff --git a/config/critters-webpack-plugin.js b/config/critters-webpack-plugin.js new file mode 100644 index 00000000..f4aeaf55 --- /dev/null +++ b/config/critters-webpack-plugin.js @@ -0,0 +1,322 @@ +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