From be8fae10f8512a79496883a259c1faffbda66270 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 17 Apr 2018 13:57:48 -0400 Subject: [PATCH 1/9] Critters: move to async/await (fixes #16), add critical font inlining / preloading --- config/critters-webpack-plugin.js | 241 ++++++++++++++++++++---------- package.json | 2 + webpack.config.js | 24 ++- 3 files changed, 185 insertions(+), 82 deletions(-) diff --git a/config/critters-webpack-plugin.js b/config/critters-webpack-plugin.js index f4aeaf558..d0a9894cd 100644 --- a/config/critters-webpack-plugin.js +++ b/config/critters-webpack-plugin.js @@ -1,13 +1,9 @@ -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'; @@ -19,10 +15,13 @@ const PARSE5_OPTS = { /** 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 + * @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) { @@ -35,44 +34,54 @@ module.exports = class CrittersWebpackPlugin { /** 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 }); - }) + 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`) */ - embedLinkedStylesheet (link, compilation, outputPath) { + async embedLinkedStylesheet (link, compilation, outputPath) { const href = link.getAttribute('href'); const document = link.ownerDocument; @@ -85,37 +94,59 @@ module.exports = class CrittersWebpackPlugin { // 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 => { + // 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