From 2d0256275399165c16f2c2b284f2040da31f6b12 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Mon, 2 Apr 2018 22:49:19 -0400 Subject: [PATCH 01/19] tabs -> spaces --- src/index.html | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/index.html b/src/index.html index 6fecc701..dac706e1 100644 --- a/src/index.html +++ b/src/index.html @@ -1,16 +1,23 @@ - - - Squoosh - - - - - - - -
- - + + + Squoosh + + + + + + + +
+ + + \ No newline at end of file From 7bf2ba4690a1ab06a935ca65a09d147b66153946 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Mon, 2 Apr 2018 22:51:01 -0400 Subject: [PATCH 02/19] Initial version of webpack-inline-critical-css-plugin --- ...html-webpack-inline-critical-css-plugin.js | 406 ++++++++++++++++++ package.json | 7 +- src/style/index.scss | 2 +- webpack.config.js | 3 + 4 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 config/html-webpack-inline-critical-css-plugin.js diff --git a/config/html-webpack-inline-critical-css-plugin.js b/config/html-webpack-inline-critical-css-plugin.js new file mode 100644 index 00000000..1bb0dd5b --- /dev/null +++ b/config/html-webpack-inline-critical-css-plugin.js @@ -0,0 +1,406 @@ +const fs = require('fs'); +const path = require('path'); +// const jsdom = require('jsdom'); +// const cssTree = require('css-tree'); +const parse5 = require('parse5'); +const nwmatcher = require('nwmatcher'); +const css = require('css'); + +const treeUtils = parse5.treeAdapters.htmlparser2; + +const PLUGIN_NAME = 'html-webpack-inline-critical-css-plugin'; + +const PARSE5_OPTS = { + treeAdapter: treeUtils +}; + +function defineProperties(obj, properties) { + for (let i in properties) { + let value = properties[i]; + Object.defineProperty(obj, i, typeof value === 'function' ? { value: value } : value); + } +} + +const ElementExtensions = { + nodeName: { + get: function() { + return this.tagName; + } + }, + appendChild: function (child) { + treeUtils.appendChild(this, child); + return child; + }, + removeChild: function (child) { + treeUtils.detachNode(child); + }, + setAttribute: function (name, value) { + if (this.attribs == null) this.attribs = {}; + if (value == null) value = ''; + this.attribs[name] = value; + }, + removeAttribute: function(name) { + if (this.attribs != null) { + delete this.attribs[name]; + } + }, + getAttribute: function (name) { + return this.attribs != null && this.attribs[name]; + }, + hasAttribute: function (name) { + return this.attribs != null && this.attribs[name] != null; + }, + getAttributeNode: function (name) { + const value = this.getAttribute(name); + if (value!=null) return { specified: true, value: value }; + }, + getElementsByTagName: getElementsByTagName +}; + +const DocumentExtensions = { + nodeType: { + get: function () { + return 11; + } + }, + createElement: function (name) { + return treeUtils.createElement(name, null, []); + }, + createTextNode: function (text) { + let scratch = this.$$scratchElement; + treeUtils.insertText(scratch, text); + let node = scratch.lastChild; + treeUtils.detachNode(node); + return node; + }, + querySelector: function (sel) { + return this.$match.first(sel); + }, + querySelectorAll: function (sel) { + return this.$match.select(sel); + }, + getElementsByTagName: getElementsByTagName, + // https://github.com/dperini/nwmatcher/blob/3edb471e12ce7f7d46dc1606c7f659ff45675a29/src/nwmatcher.js#L353 + addEventListener: Object +}; + +module.exports = class HtmlWebpackInlineCriticalCssPlugin { + constructor(options) { + this.options = options || {}; + } + + apply(compiler) { + const self = this; + const outputPath = compiler.options.output.path; + compiler.hooks.compilation.tap(PLUGIN_NAME, function (compilation) { + compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, function (htmlPluginData, callback) { + const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS); + + defineProperties(document, DocumentExtensions); + document.documentElement = document.childNodes[document.childNodes.length - 1]; + + let scratch = document.$$scratchElement = document.createElement('div'); + let elementProto = Object.getPrototypeOf(scratch); + defineProperties(elementProto, ElementExtensions); + // Object.assign(elementProto, ElementExtensions); + elementProto.ownerDocument = document; + + document.$match = nwmatcher({ document }); + document.$match.configure({ + CACHING: false, + USE_QSAPI: false, + USE_HTML5: false + }); + + // const head = document.$match.byTag('head'); + + let externalSheets; + externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); + + Promise.all(externalSheets.map(function(link) { + const href = link.getAttribute('href'); + if (href.match(/^(https?:)?\/\//)) return Promise.resolve(); + const filename = path.resolve(outputPath, href.replace(/^\//, '')); + // console.log('>>>> '+ filename); + let asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')]; + let promise = asset ? Promise.resolve(asset.source()) : readFile(filename); + return promise.then(function (sheet) { + const style = document.createElement('style'); + style.$$name = href; + style.appendChild(document.createTextNode(sheet)); + // head.appendChild(style); + link.parentNode.appendChild(style); // @TODO insertBefore + }); + })) + .then(function() { + const styles = document.$match.byTag('style'); + return Promise.all(styles.map(function (style) { + return self.processStyle(style, document); + })); + }) + .then(function () { + const html = parse5.serialize(document, PARSE5_OPTS); + callback(null, { html }); + }) + .catch(function (err) { + callback(err); + }); + + // for (let i = 0; i < styles.length; i++) { + // this.processStyle(styles[i], document); + // } + + // const html = parse5.serialize(document); + // return { html }; + + /* + const dom = new jsdom.JSDOM(htmlPluginData.html); + const styles = dom.window.document.querySelectorAll('style'); + for (let i=0; i0 && style.childNodes.map(getNodeValue).join('\n'); + if (!sheet) return done; + + // let ast = cssTree.parse(sheet, { + // positions: false, + // // parseRulePrelude: false, + // // parseAtrulePrelude: false, + // parseValue: false + // }); + // cssTree.walk(ast, { + // visit: 'Rule', + // enter(node, item, list) { + // console.log(this); + // if (node.prelude && node.prelude.type === 'SelectorList' && document.querySelector(cssTree.generate(node.prelude)) == null) { + // list.remove(item); + // } + // // if (node.type === 'Selector') { + // // } + // } + // }); + // sheet = cssTree.generate(ast); + + let ast = css.parse(sheet); + + let before = sheet; + + visit(ast, function (rule) { + if (rule.type==='rule') { + rule.selectors = rule.selectors.filter(function (sel) { + if (sel.match(/:(hover|focus|active)([.[#~&^:*]|\s|\n|$)/)) return false; + sel = sel.replace(/::?(?:[a-z-]+)([.[#~&^:*]|\s|\n|$)/gi, '$1'); + return document.querySelector(sel, document) != null; + }); + // If there are no matched selectors, replace the rule with a comment. + if (rule.selectors.length===0) { + rule.type = 'comment'; + rule.comment = ''; + delete rule.selectors; + delete rule.declarations; + } + } + + if (rule.rules) { + // Filter out comments + rule.rules = rule.rules.filter(notComment); + // If there are no remaining rules, replace the parent rule with a comment. + if (rule.rules.length===0) { + rule.type = 'comment'; + rule.comment = ''; + delete rule.rules; + } + } + }); + + sheet = css.stringify(ast, { compress: true }); + + if (this.options.minimize || this.options.compress || this.options.minify) { + const cssnano = require('cssnano'); + done = cssnano.process(sheet, {}, { preset: 'default' }).then(function (result) { + sheet = result.css; + }); + } + + return done.then(function () { + if (sheet.trim().length===0) { + // all rules were removed, get rid of the style element entirely + sheet.parentNode.removeChild(sheet); + } + else { + // replace the stylesheet inline + while (style.lastChild) { + style.removeChild(style.lastChild); + } + style.appendChild(document.createTextNode(sheet)); + } + const name = style.$$name; + console.log('\u001b[32mInlined CSS Size' + (name ? (' ('+name+')') : '') + ': ' + (sheet.length / 1000).toPrecision(2) + 'kb (' + ((before.length - sheet.length) / before.length * 100 | 0) + '% of original ' + (before.length / 1000).toPrecision(2) + 'kb)\u001b[39m'); + }); + + /* + console.dir(style); + if (style.sheet == null) return; + + const document = style.ownerDocument; + + // function serializeStyle(styleDeclaration) { + // let str = ''; + // for (let i=0; i { + // if (keyframe.type == 'keyframe') { + // fn(keyframe, rule); + // } + // }); + // return; + // } + + // @charset, @import etc + // if (!rule.declarations && !rule.keyframes) return; + + fn(rule); + }); +} + + +function readFile(file) { + return new Promise(function (resolve, reject) { + fs.readFile(file, 'utf8', function (err, contents) { + if (err) reject(err); + else resolve(contents); + }); + }); +} + +function getNodeValue(node) { + return node.nodeValue; +} + +function notComment(rule) { + return rule.type !== 'comment'; +} + +function getElementsByTagName(tagName) { + let stack = [this]; + let matches = []; + let isWildCard = tagName === '*'; + let tagNameUpper = tagName.toUpperCase(); + while (stack.length !== 0) { + let el = stack.pop(); + let child = el.lastChild; + while (child) { + if (child.nodeType === 1) stack.push(child); + child = child.previousSibling; + } + if (isWildCard || (el.tagName != null && (el.tagName === tagNameUpper || el.tagName.toUpperCase() === tagNameUpper))) { + matches.push(el); + } + } + return matches; +} diff --git a/package.json b/package.json index 5b032fe7..0e032910 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ 2, 2 ], - "react/prefer-stateless-function": 0 + "object-shorthand": 0, + "prefer-arrow-callback": 0 } }, "eslintIgnore": [ @@ -34,8 +35,10 @@ "babel-plugin-transform-react-remove-prop-types": "^0.4.13", "babel-preset-env": "^1.6.1", "babel-register": "^6.26.0", + "chalk": "^2.3.2", "clean-webpack-plugin": "^0.1.19", "copy-webpack-plugin": "^4.5.1", + "css": "^2.2.1", "css-loader": "^0.28.11", "ejs-loader": "^0.3.1", "eslint": "^4.18.2", @@ -48,6 +51,8 @@ "jsdom": "^11.6.2", "mini-css-extract-plugin": "^0.3.0", "node-sass": "^4.7.2", + "nwmatcher": "^1.4.4", + "parse5": "^4.0.0", "preact-render-to-string": "^3.7.0", "preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin", "progress-bar-webpack-plugin": "^1.11.0", diff --git a/src/style/index.scss b/src/style/index.scss index 2795b423..5d7073ee 100644 --- a/src/style/index.scss +++ b/src/style/index.scss @@ -1,7 +1,7 @@ // @import './material-icons.scss'; // @import 'material-components-web/material-components-web'; @import './reset.scss'; -@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); +// @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); html, body { height: 100%; diff --git a/webpack.config.js b/webpack.config.js index d2d18149..89df2021 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,6 +9,7 @@ const PreloadWebpackPlugin = require('preload-webpack-plugin'); const ReplacePlugin = require('webpack-plugin-replace'); const CopyPlugin = require('copy-webpack-plugin'); const WorkboxPlugin = require('workbox-webpack-plugin'); +const HtmlInlineCssPlugin = require('./config/html-webpack-inline-critical-css-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; @@ -174,6 +175,8 @@ module.exports = function(_, env) { // Inject for resources isProd && new PreloadWebpackPlugin(), + isProd && new HtmlInlineCssPlugin(), + // Inline constants during build, so they can be folded by UglifyJS. new webpack.DefinePlugin({ From 20757d9177b02213533c807e2f6a954b799f65a4 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Mon, 2 Apr 2018 22:52:30 -0400 Subject: [PATCH 03/19] Remove commented-out code. --- ...html-webpack-inline-critical-css-plugin.js | 150 ------------------ 1 file changed, 150 deletions(-) diff --git a/config/html-webpack-inline-critical-css-plugin.js b/config/html-webpack-inline-critical-css-plugin.js index 1bb0dd5b..b6258c48 100644 --- a/config/html-webpack-inline-critical-css-plugin.js +++ b/config/html-webpack-inline-critical-css-plugin.js @@ -1,7 +1,5 @@ const fs = require('fs'); const path = require('path'); -// const jsdom = require('jsdom'); -// const cssTree = require('css-tree'); const parse5 = require('parse5'); const nwmatcher = require('nwmatcher'); const css = require('css'); @@ -102,7 +100,6 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { let scratch = document.$$scratchElement = document.createElement('div'); let elementProto = Object.getPrototypeOf(scratch); defineProperties(elementProto, ElementExtensions); - // Object.assign(elementProto, ElementExtensions); elementProto.ownerDocument = document; document.$match = nwmatcher({ document }); @@ -112,8 +109,6 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { USE_HTML5: false }); - // const head = document.$match.byTag('head'); - let externalSheets; externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); @@ -121,14 +116,12 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { const href = link.getAttribute('href'); if (href.match(/^(https?:)?\/\//)) return Promise.resolve(); const filename = path.resolve(outputPath, href.replace(/^\//, '')); - // console.log('>>>> '+ filename); let asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')]; let promise = asset ? Promise.resolve(asset.source()) : readFile(filename); return promise.then(function (sheet) { const style = document.createElement('style'); style.$$name = href; style.appendChild(document.createTextNode(sheet)); - // head.appendChild(style); link.parentNode.appendChild(style); // @TODO insertBefore }); })) @@ -145,24 +138,6 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { .catch(function (err) { callback(err); }); - - // for (let i = 0; i < styles.length; i++) { - // this.processStyle(styles[i], document); - // } - - // const html = parse5.serialize(document); - // return { html }; - - /* - const dom = new jsdom.JSDOM(htmlPluginData.html); - const styles = dom.window.document.querySelectorAll('style'); - for (let i=0; i0 && style.childNodes.map(getNodeValue).join('\n'); if (!sheet) return done; - // let ast = cssTree.parse(sheet, { - // positions: false, - // // parseRulePrelude: false, - // // parseAtrulePrelude: false, - // parseValue: false - // }); - // cssTree.walk(ast, { - // visit: 'Rule', - // enter(node, item, list) { - // console.log(this); - // if (node.prelude && node.prelude.type === 'SelectorList' && document.querySelector(cssTree.generate(node.prelude)) == null) { - // list.remove(item); - // } - // // if (node.type === 'Selector') { - // // } - // } - // }); - // sheet = cssTree.generate(ast); - let ast = css.parse(sheet); let before = sheet; @@ -247,95 +203,6 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { const name = style.$$name; console.log('\u001b[32mInlined CSS Size' + (name ? (' ('+name+')') : '') + ': ' + (sheet.length / 1000).toPrecision(2) + 'kb (' + ((before.length - sheet.length) / before.length * 100 | 0) + '% of original ' + (before.length / 1000).toPrecision(2) + 'kb)\u001b[39m'); }); - - /* - console.dir(style); - if (style.sheet == null) return; - - const document = style.ownerDocument; - - // function serializeStyle(styleDeclaration) { - // let str = ''; - // for (let i=0; i { - // if (keyframe.type == 'keyframe') { - // fn(keyframe, rule); - // } - // }); - // return; - // } - - // @charset, @import etc - // if (!rule.declarations && !rule.keyframes) return; - fn(rule); }); } From 8e6a48b94dd196d91203507984e2940066b859f8 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 3 Apr 2018 08:52:53 -0400 Subject: [PATCH 04/19] `let` -> `const` --- ...html-webpack-inline-critical-css-plugin.js | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/config/html-webpack-inline-critical-css-plugin.js b/config/html-webpack-inline-critical-css-plugin.js index b6258c48..1240d158 100644 --- a/config/html-webpack-inline-critical-css-plugin.js +++ b/config/html-webpack-inline-critical-css-plugin.js @@ -13,8 +13,8 @@ const PARSE5_OPTS = { }; function defineProperties(obj, properties) { - for (let i in properties) { - let value = properties[i]; + for (const i in properties) { + const value = properties[i]; Object.defineProperty(obj, i, typeof value === 'function' ? { value: value } : value); } } @@ -65,9 +65,9 @@ const DocumentExtensions = { return treeUtils.createElement(name, null, []); }, createTextNode: function (text) { - let scratch = this.$$scratchElement; + const scratch = this.$$scratchElement; treeUtils.insertText(scratch, text); - let node = scratch.lastChild; + const node = scratch.lastChild; treeUtils.detachNode(node); return node; }, @@ -97,8 +97,8 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { defineProperties(document, DocumentExtensions); document.documentElement = document.childNodes[document.childNodes.length - 1]; - let scratch = document.$$scratchElement = document.createElement('div'); - let elementProto = Object.getPrototypeOf(scratch); + const scratch = document.$$scratchElement = document.createElement('div'); + const elementProto = Object.getPrototypeOf(scratch); defineProperties(elementProto, ElementExtensions); elementProto.ownerDocument = document; @@ -109,15 +109,14 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { USE_HTML5: false }); - let externalSheets; - externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); + const externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); Promise.all(externalSheets.map(function(link) { const href = link.getAttribute('href'); if (href.match(/^(https?:)?\/\//)) return Promise.resolve(); const filename = path.resolve(outputPath, href.replace(/^\//, '')); - let asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')]; - let promise = asset ? Promise.resolve(asset.source()) : readFile(filename); + const asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')]; + const promise = asset ? Promise.resolve(asset.source()) : readFile(filename); return promise.then(function (sheet) { const style = document.createElement('style'); style.$$name = href; @@ -147,9 +146,9 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { let sheet = style.childNodes.length>0 && style.childNodes.map(getNodeValue).join('\n'); if (!sheet) return done; - let ast = css.parse(sheet); + const ast = css.parse(sheet); - let before = sheet; + const before = sheet; visit(ast, function (rule) { if (rule.type==='rule') { @@ -237,12 +236,12 @@ function notComment(rule) { } function getElementsByTagName(tagName) { - let stack = [this]; - let matches = []; - let isWildCard = tagName === '*'; - let tagNameUpper = tagName.toUpperCase(); + const stack = [this]; + const matches = []; + const isWildCard = tagName === '*'; + const tagNameUpper = tagName.toUpperCase(); while (stack.length !== 0) { - let el = stack.pop(); + const el = stack.pop(); let child = el.lastChild; while (child) { if (child.nodeType === 1) stack.push(child); From 82d33ea91cd8674f7195b2ae2533ce53dbb0ce41 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 3 Apr 2018 08:53:05 -0400 Subject: [PATCH 05/19] Add `prefer-const` eslint rule --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e032910..08521b0f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ 2 ], "object-shorthand": 0, - "prefer-arrow-callback": 0 + "prefer-arrow-callback": 0, + "prefer-const": 1 } }, "eslintIgnore": [ From 34c47242a922af7a272cbdb35669bdac42c72bd8 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 3 Apr 2018 09:21:47 -0400 Subject: [PATCH 06/19] Don't strip `:hover`, `:focus` or `:active` rules. --- config/html-webpack-inline-critical-css-plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/html-webpack-inline-critical-css-plugin.js b/config/html-webpack-inline-critical-css-plugin.js index 1240d158..39270652 100644 --- a/config/html-webpack-inline-critical-css-plugin.js +++ b/config/html-webpack-inline-critical-css-plugin.js @@ -153,7 +153,7 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { visit(ast, function (rule) { if (rule.type==='rule') { rule.selectors = rule.selectors.filter(function (sel) { - if (sel.match(/:(hover|focus|active)([.[#~&^:*]|\s|\n|$)/)) return false; + // Remove unknown pseudos as they break nwmatcher sel = sel.replace(/::?(?:[a-z-]+)([.[#~&^:*]|\s|\n|$)/gi, '$1'); return document.querySelector(sel, document) != null; }); From 0066e203150b45be3d60a954137e587573ad36a3 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 3 Apr 2018 09:59:05 -0400 Subject: [PATCH 07/19] - Rename to critters-webpack-plugin - Format output using pretty-bytes - Insert inlined style tags immediately after their source link tags - Add option to turn off external stylesheet processing - Add `async` option that converts critical'd external sheets to `` --- ...s-plugin.js => critters-webpack-plugin.js} | 29 +++++++++++++++---- webpack.config.js | 7 +++-- 2 files changed, 29 insertions(+), 7 deletions(-) rename config/{html-webpack-inline-critical-css-plugin.js => critters-webpack-plugin.js} (84%) diff --git a/config/html-webpack-inline-critical-css-plugin.js b/config/critters-webpack-plugin.js similarity index 84% rename from config/html-webpack-inline-critical-css-plugin.js rename to config/critters-webpack-plugin.js index 39270652..6aa631f1 100644 --- a/config/html-webpack-inline-critical-css-plugin.js +++ b/config/critters-webpack-plugin.js @@ -3,10 +3,11 @@ const path = require('path'); const parse5 = require('parse5'); const nwmatcher = require('nwmatcher'); const css = require('css'); +const prettyBytes = require('pretty-bytes'); const treeUtils = parse5.treeAdapters.htmlparser2; -const PLUGIN_NAME = 'html-webpack-inline-critical-css-plugin'; +const PLUGIN_NAME = 'critters-webpack-plugin'; const PARSE5_OPTS = { treeAdapter: treeUtils @@ -25,6 +26,11 @@ const ElementExtensions = { return this.tagName; } }, + insertBefore: function (child, referenceNode) { + if (!referenceNode) return this.appendChild(child); + treeUtils.insertBefore(this, child, referenceNode); + return child; + }, appendChild: function (child) { treeUtils.appendChild(this, child); return child; @@ -82,7 +88,13 @@ const DocumentExtensions = { addEventListener: Object }; -module.exports = class HtmlWebpackInlineCriticalCssPlugin { +/** 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=true] If `false`, only already-inline stylesheets will be reduced to critical rules. + */ +module.exports = class CrittersWebpackPlugin { constructor(options) { this.options = options || {}; } @@ -112,6 +124,7 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { const externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); Promise.all(externalSheets.map(function(link) { + if (self.options.external===false) return; const href = link.getAttribute('href'); if (href.match(/^(https?:)?\/\//)) return Promise.resolve(); const filename = path.resolve(outputPath, href.replace(/^\//, '')); @@ -121,7 +134,12 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { const style = document.createElement('style'); style.$$name = href; style.appendChild(document.createTextNode(sheet)); - link.parentNode.appendChild(style); // @TODO insertBefore + link.parentNode.insertBefore(style, link.nextSibling); + if (self.options.async) { + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'style'); + link.setAttribute('onload', "this.rel='stylesheet'"); + } }); })) .then(function() { @@ -199,8 +217,9 @@ module.exports = class HtmlWebpackInlineCriticalCssPlugin { } style.appendChild(document.createTextNode(sheet)); } - const name = style.$$name; - console.log('\u001b[32mInlined CSS Size' + (name ? (' ('+name+')') : '') + ': ' + (sheet.length / 1000).toPrecision(2) + 'kb (' + ((before.length - sheet.length) / before.length * 100 | 0) + '% of original ' + (before.length / 1000).toPrecision(2) + 'kb)\u001b[39m'); + const name = style.$$name ? style.$$name.replace(/^\//, '') : 'inline CSS'; + const percent = (before.length - sheet.length) / before.length * 100 | 0; + console.log('\u001b[32mCritters: inlined ' + prettyBytes(sheet.length) + ' (' + percent + '% of original ' + prettyBytes(before.length) + ') of ' + name + '.\u001b[39m'); }); } }; diff --git a/webpack.config.js b/webpack.config.js index 89df2021..d640dbf9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,7 +9,7 @@ const PreloadWebpackPlugin = require('preload-webpack-plugin'); const ReplacePlugin = require('webpack-plugin-replace'); const CopyPlugin = require('copy-webpack-plugin'); const WorkboxPlugin = require('workbox-webpack-plugin'); -const HtmlInlineCssPlugin = require('./config/html-webpack-inline-critical-css-plugin'); +const CrittersPlugin = require('./config/critters-webpack-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; @@ -175,8 +175,11 @@ module.exports = function(_, env) { // Inject for resources isProd && new PreloadWebpackPlugin(), - isProd && new HtmlInlineCssPlugin(), + isProd && new CrittersPlugin({ + // convert critical'd to + async: true + }), // Inline constants during build, so they can be folded by UglifyJS. new webpack.DefinePlugin({ From 306fecee7c2ec718ea9f0ef5a2cea8251f265583 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Tue, 3 Apr 2018 10:50:00 -0400 Subject: [PATCH 08/19] Use modern JS features, add lots of comments. --- config/critters-webpack-plugin.js | 284 +++++++++++++++++------------- 1 file changed, 157 insertions(+), 127 deletions(-) diff --git a/config/critters-webpack-plugin.js b/config/critters-webpack-plugin.js index 6aa631f1..2d012446 100644 --- a/config/critters-webpack-plugin.js +++ b/config/critters-webpack-plugin.js @@ -5,94 +5,20 @@ const nwmatcher = require('nwmatcher'); const css = require('css'); const prettyBytes = require('pretty-bytes'); -const treeUtils = parse5.treeAdapters.htmlparser2; +const treeAdapter = parse5.treeAdapters.htmlparser2; const PLUGIN_NAME = 'critters-webpack-plugin'; const PARSE5_OPTS = { - treeAdapter: treeUtils -}; - -function defineProperties(obj, properties) { - for (const i in properties) { - const value = properties[i]; - Object.defineProperty(obj, i, typeof value === 'function' ? { value: value } : value); - } -} - -const ElementExtensions = { - nodeName: { - get: function() { - return this.tagName; - } - }, - insertBefore: function (child, referenceNode) { - if (!referenceNode) return this.appendChild(child); - treeUtils.insertBefore(this, child, referenceNode); - return child; - }, - appendChild: function (child) { - treeUtils.appendChild(this, child); - return child; - }, - removeChild: function (child) { - treeUtils.detachNode(child); - }, - setAttribute: function (name, value) { - if (this.attribs == null) this.attribs = {}; - if (value == null) value = ''; - this.attribs[name] = value; - }, - removeAttribute: function(name) { - if (this.attribs != null) { - delete this.attribs[name]; - } - }, - getAttribute: function (name) { - return this.attribs != null && this.attribs[name]; - }, - hasAttribute: function (name) { - return this.attribs != null && this.attribs[name] != null; - }, - getAttributeNode: function (name) { - const value = this.getAttribute(name); - if (value!=null) return { specified: true, value: value }; - }, - getElementsByTagName: getElementsByTagName -}; - -const DocumentExtensions = { - nodeType: { - get: function () { - return 11; - } - }, - createElement: function (name) { - return treeUtils.createElement(name, null, []); - }, - createTextNode: function (text) { - const scratch = this.$$scratchElement; - treeUtils.insertText(scratch, text); - const node = scratch.lastChild; - treeUtils.detachNode(node); - return node; - }, - querySelector: function (sel) { - return this.$match.first(sel); - }, - querySelectorAll: function (sel) { - return this.$match.select(sel); - }, - getElementsByTagName: getElementsByTagName, - // https://github.com/dperini/nwmatcher/blob/3edb471e12ce7f7d46dc1606c7f659ff45675a29/src/nwmatcher.js#L353 - addEventListener: Object + 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=true] If `false`, only already-inline stylesheets will be reduced to critical rules. + * @param {Boolean} [options.async=false] Convert critical-inlined external stylesheets to load asynchronously (via link rel="preload") + * @param {Boolean} [options.minify=false] Minify resulting critical CSS using cssnano */ module.exports = class CrittersWebpackPlugin { constructor(options) { @@ -100,59 +26,60 @@ module.exports = class CrittersWebpackPlugin { } apply(compiler) { - const self = this; const outputPath = compiler.options.output.path; - compiler.hooks.compilation.tap(PLUGIN_NAME, function (compilation) { - compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, function (htmlPluginData, callback) { + + // 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); - defineProperties(document, DocumentExtensions); - document.documentElement = document.childNodes[document.childNodes.length - 1]; + // `external:false` skips processing of external sheets + const externalSheets = this.options.external===false ? [] : document.querySelectorAll('link[rel="stylesheet"]'); - const scratch = document.$$scratchElement = document.createElement('div'); - const elementProto = Object.getPrototypeOf(scratch); - defineProperties(elementProto, ElementExtensions); - elementProto.ownerDocument = document; - - document.$match = nwmatcher({ document }); - document.$match.configure({ - CACHING: false, - USE_QSAPI: false, - USE_HTML5: false - }); - - const externalSheets = document.querySelectorAll('link[rel="stylesheet"]'); - - Promise.all(externalSheets.map(function(link) { - if (self.options.external===false) return; + Promise.all(externalSheets.map(link => { const href = link.getAttribute('href'); + + // skip network resources if (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); - return promise.then(function (sheet) { + return promise.then(sheet => { + // the reduced critical CSS gets injected into a new