From 7bf2ba4690a1ab06a935ca65a09d147b66153946 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Mon, 2 Apr 2018 22:51:01 -0400 Subject: [PATCH] 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({