Skip to content

Commit

Permalink
Add support for multi-dimension critical css.
Browse files Browse the repository at this point in the history
TLDR

This is useful when you want to deliver multiple dimensions
of critical css. Take for instance when your site has different elements
visible at different resolutions. Prior to this work you were limited to
multiple critical passes and inclusions or simply picking one
resolution.

Why is this better?

I am glad you asked yourself that. Since we use
clean-css we can intellegently merge the multiple resolution snapshots
making for smaller and better multi snapshot rule generation.

Why did I do this?

While working on a website I noticed that we were getting a flash of
styles after the normal stylesheet was loaded. This was not so bad on a
computer screen (since that was the dimension that we targeted) however
when on a phone it was quite a jarring experience.
  • Loading branch information
samccone committed Feb 17, 2015
1 parent f5f3247 commit 925e7ce
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 86 deletions.
21 changes: 21 additions & 0 deletions README.md
Expand Up @@ -110,6 +110,26 @@ critical.generate({
});
```

### Generate critical-path CSS with multiple resolutions

When your site is adaptive and you want to deliver critical CSS for multiple screen resolutions this is a useful option.
*note:* (your final output will be minified as to eliminate duplicate rule inclusion)

```js
critical.generate({
base: 'test/',
src: 'index.html',
dest: 'styles/main.css',
dimensions: [{
height: 200,
width: 500
}, {
height: 900,
width: 1200
}]
});
```

### Inline `<style>` / critical CSS from generation

Basic usage:
Expand Down Expand Up @@ -156,6 +176,7 @@ critical.inline({
| dest | `string` | Location of where to save the output of an operation |
| width | `integer` | (Generation only) Width of the target viewport |
| height | `integer` | (Generation only) Height of the target viewport |
| dimensions | `array` | (Generation only) an array of objects containing height and width.
| minify | `boolean` | Enable minification of CSS output |
| extract | `boolean` | Remove the inlined styles from any stylesheets referenced in the HTML. It generates new references based on extracted content so it's safe to use for multiple HTML files referencing the same stylesheet|
| styleTarget | `string` | (`generateInline` only) Destination for critical-path styles |
Expand Down
187 changes: 101 additions & 86 deletions index.js
Expand Up @@ -22,6 +22,22 @@ var tmpfile = Promise.promisify(tmp.file);

tmp.setGracefulCleanup();

/**
* returns a string of combined and deduped css rules.
* @param cssArray
* @returns {String}
*/
function combineCss(cssArray) {
if (cssArray.length == 1) {
return cssArray[0].toString();
}

return new CleanCSS({mediaMerging: true}).minify(
_.invoke(cssArray, 'toString')
.join(' ')
);
}

/**
* get path from result array
* @param resultArray
Expand Down Expand Up @@ -80,7 +96,7 @@ function getContentPromise(opts) {
* Critical path CSS generation
* @param {object} opts Options
* @param {function} cb Callback
* @accepts src, base, width, height, dest
* @accepts src, base, width, height, dimensions, dest
*/
exports.generate = function (opts, cb) {
opts = opts || {};
Expand All @@ -90,101 +106,100 @@ exports.generate = function (opts, cb) {
throw new Error('A valid source and base path are required.');
}

if (!opts.height) {
opts.height = 320;
if (!opts.dimensions) {
opts.dimensions = [{
height: opts.height || 320,
width: opts.width || 480
}]
}

if (!opts.width) {
opts.width = 480;
}

// use content to fetch used css files
getContentPromise(opts).then(function (html) {
// consider opts.css and map to array if it's a string
if (opts.css) {
return typeof opts.css === 'string' ? [opts.css] : opts.css;
}

// Oust extracts a list of your stylesheets (ignoring remote stylesheets)
return oust(html.toString(), 'stylesheets').filter(function (href) {
return !/(^\/\/)|(:\/\/)/.test(href);
}).map(function (href) {
return path.join(opts.base, href);
});
// read files
}).map(function (fileName) {
return fs.readFileAsync(fileName, 'utf8').then(function (content) {
// get path to css file
var dir = path.dirname(fileName);
var maxFileSize = opts.maxImageFileSize || 10240;

// #40 already inlined background images cause problems with imageinliner
if (opts.inlineImages) {
content = imageInliner.css(content.toString(), {
maxImageFileSize: maxFileSize,
cssBasePath: dir,
rootImagePath: opts.base
});
Promise.map(opts.dimensions, function(dimensions) {
// use content to fetch used css files
return getContentPromise(opts).then(function (html) {
// consider opts.css and map to array if it's a string
if (opts.css) {
return typeof opts.css === 'string' ? [opts.css] : opts.css;
}

// normalize relative paths
return content.toString().replace(/url\(['"]?([^'"\)]+)['"]?\)/g, function (match, filePath) {
// do nothing for absolute paths, urls and data-uris
if (/^data\:/.test(filePath) || /(?:^\/)|(?:\:\/\/)/.test(filePath)) {
return match;
// Oust extracts a list of your stylesheets (ignoring remote stylesheets)
return oust(html.toString(), 'stylesheets').filter(function (href) {
return !/(^\/\/)|(:\/\/)/.test(href);
}).map(function (href) {
return path.join(opts.base, href);
});
// read files
}).map(function (fileName) {
return fs.readFileAsync(fileName, 'utf8').then(function (content) {
// get path to css file
var dir = path.dirname(fileName);
var maxFileSize = opts.maxImageFileSize || 10240;

// #40 already inlined background images cause problems with imageinliner
if (opts.inlineImages) {
content = imageInliner.css(content.toString(), {
maxImageFileSize: maxFileSize,
cssBasePath: dir,
rootImagePath: opts.base
});
}
// normalize relative paths
return content.toString().replace(/url\(['"]?([^'"\)]+)['"]?\)/g, function (match, filePath) {
// do nothing for absolute paths, urls and data-uris
if (/^data\:/.test(filePath) || /(?:^\/)|(?:\:\/\/)/.test(filePath)) {
return match;
}

// create path relative to opts.base
var relativeToBase = path.relative(path.resolve(opts.base), path.resolve(path.join(dir, filePath)));

// prepend / to make it absolute
return normalizePath(match.replace(filePath, path.join('/', relativeToBase)));
});
});

// create path relative to opts.base
var relativeToBase = path.relative(path.resolve(opts.base), path.resolve(path.join(dir, filePath)));
// combine all css files to one bid stylesheet
}).reduce(function (total, contents) {
return total + os.EOL + contents;

// prepend / to make it absolute
return normalizePath(match.replace(filePath, path.join('/', relativeToBase)));
// write contents to tmp file
}, '').then(function (css) {
var csspath = tempfile('.css');
return fs.writeFileAsync(csspath,css).then(function () {
return csspath;
});
});

// combine all css files to one bid stylesheet
}).reduce(function (total, contents) {
return total + os.EOL + contents;

// write contents to tmp file
}, '').then(function (css) {
var csspath = tempfile('.css');
return fs.writeFileAsync(csspath,css).then(function () {
return csspath;
});

// let penthouseAsync do the rest
}).then(function (csspath) {
return penthouseAsync({
url: normalizePath(opts.url),
css: csspath,
// What viewports do you care about?
width: opts.width, // viewport width
height: opts.height // viewport height
});

// Penthouse callback
}).then(function (criticalCSS) {
if (opts.minify === true) {
criticalCSS = new CleanCSS().minify(criticalCSS);
}

if (opts.dest) {
// Write critical-path CSS
return fs.writeFileAsync(path.join(opts.base, opts.dest), criticalCSS).then(function () {
return criticalCSS;
// let penthouseAsync do the rest
}).then(function (csspath) {
return penthouseAsync({
url: normalizePath(opts.url),
css: csspath,
// What viewports do you care about?
width: dimensions.width, // viewport width
height: dimensions.height // viewport height
});
} else {
})
})
.then(function (criticalCSS) {
criticalCSS = combineCss(criticalCSS);

if (opts.minify === true) {
criticalCSS = new CleanCSS().minify(criticalCSS);
}

if (opts.dest) {
// Write critical-path CSS
return fs.writeFileAsync(path.join(opts.base, opts.dest), criticalCSS).then(function () {
return criticalCSS;
}

// return err on error
}).then(function (criticalCSS) {
cb(null, criticalCSS.toString());
}).catch(function (err) {
cb(err);
// callback success
}).done();
});
} else {
return criticalCSS;
}
})
.then(function(finalCss) {
cb(null, finalCss);
})
.catch(function (err) {
cb(err);
}).done();
};

/**
Expand Down
24 changes: 24 additions & 0 deletions test/fixture/styles/critical-adaptive.css
@@ -0,0 +1,24 @@
@media screen and (min-width: 900px) {
div {
height: 400px;
background: brown;
}
}

#revenge {
background: papayawhip;
}

#of {
background: teal;
}

#guybrush {
color: pink;
}

#threepwood {
background: 'orange';
content: 'monkey island';
}

19 changes: 19 additions & 0 deletions test/test.js
Expand Up @@ -54,6 +54,25 @@ describe('Module', function () {
});
});

it('generates multi-dimension critical-path CSS successfully', function (done) {
var expected = fs.readFileSync('fixture/test-adaptive-final.css', 'utf8');
critical.generate({
base: 'fixture/',
src: 'test-adaptive.html',
dimensions: [{
width: 100,
height: 70
}, {
width: 1000,
height: 70
}]
}, function (err, output) {
assert.strictEqual(stripWhitespace(output), stripWhitespace(expected));
done();
});
});


it('generates minified critical-path CSS successfully', function (done) {
var expected = fs.readFileSync('fixture/styles/critical-min.css', 'utf8');

Expand Down

0 comments on commit 925e7ce

Please sign in to comment.