Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Critters: async/await & font handling #18

Merged
merged 1 commit into from
Apr 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 164 additions & 77 deletions config/critters-webpack-plugin.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 <link rel="stylesheet"> into <body> 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 <link rel="stylesheet"> into <body> 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) {
Expand All @@ -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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Webpack's Compiler#inputFileSystem (docs). I couldn't use util.promisify because memory-fs relies on the calling context, unlike Node's fs.*.

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 <link rel="stylesheet"> (assuming it passes `options.filter`) */
embedLinkedStylesheet (link, compilation, outputPath) {
async embedLinkedStylesheet (link, compilation, outputPath) {
const href = link.getAttribute('href');
const document = link.ownerDocument;

Expand All @@ -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 <style> tag
const style = document.createElement('style');
style.appendChild(document.createTextNode(sheet));
link.parentNode.insertBefore(style, link.nextSibling);

// drop a reference to the original URL onto the tag (used for reporting to console later)
style.$$name = href;

// the `async` option changes any critical'd <link rel="stylesheet"> tags to async-loaded equivalents
if (this.options.async) {
link.setAttribute('rel', 'preload');
link.setAttribute('as', 'style');
if (this.options.preload) {
const bodyLink = document.createElement('link');
bodyLink.setAttribute('rel', 'stylesheet');
bodyLink.setAttribute('href', href);
document.body.appendChild(bodyLink);
} else {
link.setAttribute('onload', "this.rel='stylesheet'");
}
const style = document.createElement('style');
style.appendChild(document.createTextNode(sheet));
link.parentNode.insertBefore(style, link.nextSibling);

// drop a reference to the original URL onto the tag (used for reporting to console later)
style.$$name = href;

// the `async` option changes any critical'd <link rel="stylesheet"> tags to async-loaded equivalents
if (this.options.async) {
link.setAttribute('rel', 'preload');
link.setAttribute('as', 'style');
if (this.options.preload === 'js') {
const script = document.createElement('script');
script.appendChild(document.createTextNode(`${cssLoaderPreamble}$loadcss(${JSON.stringify(href)})`));
link.parentNode.insertBefore(script, link.nextSibling);
cssLoaderPreamble = '';
} else if (this.options.preload) {
const bodyLink = document.createElement('link');
bodyLink.setAttribute('rel', 'stylesheet');
bodyLink.setAttribute('href', href);
document.body.appendChild(bodyLink);
} else if (this.options.media) {
// @see https://github.com/filamentgroup/loadCSS/blob/af1106cfe0bf70147e22185afa7ead96c01dec48/src/loadCSS.js#L26
link.setAttribute('rel', 'stylesheet');
link.removeAttribute('as');
link.setAttribute('media', 'only x');
link.setAttribute('onload', "this.media='" + media + "'");
} else {
link.setAttribute('onload', "this.rel='stylesheet'");
}
});
}
}

/** Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document. */
processStyle (style) {
const done = Promise.resolve();
async processStyle (style) {
const options = this.options;
const document = style.ownerDocument;
const head = document.querySelector('head');

// basically `.textContent`
let sheet = style.childNodes.length > 0 && style.childNodes.map(node => node.nodeValue).join('\n');
Expand All @@ -124,10 +155,13 @@ module.exports = class CrittersWebpackPlugin {
const before = sheet;

// Skip empty stylesheets
if (!sheet) return done;
if (!sheet) return;

const ast = css.parse(sheet);

// a string to search for font names (very loose)
let criticalFonts = '';

// Walk all CSS rules, transforming unused rules to comments (which get removed)
visit(ast, rule => {
if (rule.type === 'rule') {
Expand All @@ -142,31 +176,73 @@ module.exports = class CrittersWebpackPlugin {
if (rule.selectors.length === 0) {
return false;
}

if (rule.declarations) {
for (let i = 0; i < rule.declarations.length; i++) {
const decl = rule.declarations[i];
if (decl.property.match(/\bfont\b/i)) {
criticalFonts += ' ' + decl.value;
}
}
}
}

// If there are no remaining rules, remove the whole rule.
// keep font rules, they're handled in the second pass:
if (rule.type === 'font-face') return;

// If there are no remaining rules, remove the whole rule:
return !rule.rules || rule.rules.length !== 0;
});

sheet = css.stringify(ast, { compress: this.options.compress !== false });
const preloadedFonts = [];
visit(ast, rule => {
// only process @font-face rules in the second pass
if (rule.type !== 'font-face') return;

let family, src;
for (let i = 0; i < rule.declarations.length; i++) {
const decl = rule.declarations[i];
if (decl.property === 'src') {
// @todo parse this properly and generate multiple preloads with type="font/woff2" etc
src = (decl.value.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/) || [])[2];
} else if (decl.property === 'font-family') {
family = decl.value;
}
}

return done.then(() => {
// If all rules were removed, get rid of the style element entirely
if (sheet.trim().length === 0) {
sheet.parentNode.removeChild(sheet);
} else {
// replace the inline stylesheet with its critical'd counterpart
while (style.lastChild) {
style.removeChild(style.lastChild);
if (src && (options.fonts === true || options.preloadFonts) && preloadedFonts.indexOf(src) === -1) {
preloadedFonts.push(src);
const preload = document.createElement('link');
preload.setAttribute('rel', 'preload');
preload.setAttribute('as', 'font');
if (src.match(/:\/\//)) {
preload.setAttribute('crossorigin', 'anonymous');
}
style.appendChild(document.createTextNode(sheet));
preload.setAttribute('href', src.trim());
head.appendChild(preload);
}

// output some stats
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');
// if we're missing info or the font is unused, remove the rule:
if (!family || !src || criticalFonts.indexOf(family) === -1 || !options.fonts || options.removeFonts) return false;
});

sheet = css.stringify(ast, { compress: this.options.compress !== false });

// If all rules were removed, get rid of the style element entirely
if (sheet.trim().length === 0) {
sheet.parentNode.removeChild(sheet);
} else {
// replace the inline stylesheet with its critical'd counterpart
while (style.lastChild) {
style.removeChild(style.lastChild);
}
style.appendChild(document.createTextNode(sheet));
}

// output some stats
const name = style.$$name ? style.$$name.replace(/^\//, '') : 'inline CSS';
const percent = sheet.length / before.length * 100 | 0;
console.log('\u001b[32mCritters: inlined ' + prettyBytes(sheet.length) + ' (' + percent + '% of original ' + prettyBytes(before.length) + ') of ' + name + '.\u001b[39m');
}
};

Expand Down Expand Up @@ -230,13 +306,24 @@ function getElementsByTagName (tagName) {
);
}

const reflectedProperty = attributeName => ({
get () {
return this.getAttribute(attributeName);
},
set (value) {
this.setAttribute(attributeName, value);
}
});

/** Methods and descriptors to mix into Element.prototype */
const ElementExtensions = {
nodeName: {
get () {
return this.tagName.toUpperCase();
}
},
id: reflectedProperty('id'),
className: reflectedProperty('class'),
insertBefore (child, referenceNode) {
if (!referenceNode) return this.appendChild(child);
treeAdapter.insertBefore(this, child, referenceNode);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@
"mini-css-extract-plugin": "^0.3.0",
"node-sass": "^4.7.2",
"nwmatcher": "^1.4.4",
"optimize-css-assets-webpack-plugin": "^4.0.0",
"parse5": "^4.0.0",
"preact-render-to-string": "^3.7.0",
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
"pretty-bytes": "^4.0.2",
"progress-bar-webpack-plugin": "^1.11.0",
"sass-loader": "^6.0.7",
"script-ext-html-webpack-plugin": "^2.0.1",
Expand Down
24 changes: 19 additions & 5 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const PreloadWebpackPlugin = require('preload-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlPlugin = require('html-webpack-plugin');
const PreloadPlugin = require('preload-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
Expand Down Expand Up @@ -126,7 +127,7 @@ module.exports = function(_, env) {
}),

// Remove old files before outputting a production build:
isProd && new CleanWebpackPlugin([
isProd && new CleanPlugin([
'assets',
'**/*.{css,js,json,html}'
], {
Expand All @@ -147,6 +148,13 @@ module.exports = function(_, env) {
chunkFilename: '[name].chunk.[contenthash:5].css'
}),

new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
zindex: false,
discardComments: { removeAll: true }
}
}),

// These plugins fix infinite loop in typings-for-css-modules-loader.
// See: https://github.com/Jimdo/typings-for-css-modules-loader/issues/35
new webpack.WatchIgnorePlugin([
Expand Down Expand Up @@ -177,10 +185,16 @@ module.exports = function(_, env) {
isProd && new PreloadWebpackPlugin(),

isProd && new CrittersPlugin({
// Don't inline fonts into critical CSS, but do preload them:
preloadFonts: true,
// convert critical'd <link rel="stylesheet"> to <link rel="preload" as="style">:
async: true,
// copy original <link rel="stylesheet"> to the end of <body>:
preload: true
// Use media hack to load async (<link media="only x" onload="this.media='all'">):
media: true
// // use a $loadcss async CSS loading shim (DOM insertion to head)
// preload: 'js'
// // copy original <link rel="stylesheet"> to the end of <body>:
// preload: true
}),

// Inline constants during build, so they can be folded by UglifyJS.
Expand Down