critical-path-css-rails 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rubocop.yml +12 -0
- data/.rubocop_todo.yml +12 -0
- data/BACKLOG.md +10 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +97 -0
- data/critical-path-css-rails.gemspec +18 -0
- data/lib/critical_path_css/rails/engine.rb +6 -0
- data/lib/critical_path_css/rails/version.rb +6 -0
- data/lib/critical_path_css_rails.rb +19 -0
- data/lib/generators/critical_path_css/install_generator.rb +13 -0
- data/lib/penthouse/penthouse.js +601 -0
- data/lib/tasks/critical_path_css.rake +15 -0
- metadata +71 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c7c88b5a77a5230fc8b1f7d39cedb1fb818bc786
|
4
|
+
data.tar.gz: f7ba36dd7fe8c3392745205be27647b2a93bda70
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3101d6628e5433b0d236a4541b68f1dd9d6b46f7e22b5d7b638216c6f07bf4697c6ebd7b4105bd01e2d19296f40bd389ed59c39d77dac911455422cea824fc3d
|
7
|
+
data.tar.gz: 6dc6cc6732ed93a6932dc9288ac0acb81d82cf0806343e60ffdee71c5b729243113ce699a6356896409d99c85193f70c062197eda9ec71610148826c8ccfe557
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
data/.rubocop_todo.yml
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# This configuration was generated by
|
2
|
+
# `rubocop --auto-gen-config`
|
3
|
+
# on 2015-09-19 12:37:55 -0500 using RuboCop version 0.34.1.
|
4
|
+
# The point is for the user to remove these configuration records
|
5
|
+
# one by one as the offenses are removed from the code base.
|
6
|
+
# Note that changes in the inspected code, or installation of new
|
7
|
+
# versions of RuboCop, may require this file to be generated again.
|
8
|
+
|
9
|
+
# Offense count: 2
|
10
|
+
# Configuration parameters: AllowURI, URISchemes.
|
11
|
+
Metrics/LineLength:
|
12
|
+
Max: 91
|
data/BACKLOG.md
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# Backlog
|
2
|
+
|
3
|
+
## Tests
|
4
|
+
- Add a testing suite (preferably rspec)
|
5
|
+
|
6
|
+
## Features
|
7
|
+
- Allow the user to pass arguments to Penthouse.js, i.e. Viewport size, etc. For a list of the configurable options, please see [Penthouse](https://github.com/pocketjoso/penthouse)
|
8
|
+
- Improve installation process, if necessary
|
9
|
+
- Improve manual processes, if possible (i.e having to run the rake task to generate the the critical CSS)
|
10
|
+
- Improve implementation. Is their a better solution then using Rails.cache?
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) [2015] [Mudbug Media, Michael Misshore]
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
# critical-path-css-rails
|
2
|
+
|
3
|
+
Only load the CSS you need for the initial viewport in Rails!
|
4
|
+
|
5
|
+
This gem give you the ability to load only the CSS you *need* on an initial page view. This gives you blazin' fast rending as there's no initial network call to grab your application's CSS.
|
6
|
+
|
7
|
+
This gem assumes that you'll load the rest of the CSS asyncronously. At the moment, the suggested way is to use the [loadcss-rails](https://github.com/michael-misshore/loadcss-rails) gem.
|
8
|
+
|
9
|
+
This gem uses [PhantomJS](https://github.com/colszowka/phantomjs-gem) and [Penthouse](https://github.com/pocketjoso/penthouse) to generate the critical CSS.
|
10
|
+
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
Add `critical-path-css-rails` to your Gemfile:
|
15
|
+
|
16
|
+
```
|
17
|
+
gem 'critical-path-css-rails', '~> 0.1.0'
|
18
|
+
```
|
19
|
+
|
20
|
+
Download and install by running:
|
21
|
+
|
22
|
+
```
|
23
|
+
bundle install
|
24
|
+
```
|
25
|
+
|
26
|
+
Create the rake task that will generate your critical CSS
|
27
|
+
|
28
|
+
```
|
29
|
+
rails generator critical_path_css:install
|
30
|
+
```
|
31
|
+
|
32
|
+
This adds the following file:
|
33
|
+
|
34
|
+
* `lib/tasks/critical_path_css.rake`
|
35
|
+
|
36
|
+
|
37
|
+
## Usage
|
38
|
+
|
39
|
+
First, you'll need to configue a few variables in the rake task: `lib/tasks/critical_path_css.rake`
|
40
|
+
|
41
|
+
* `@base_url`: Change the url's here to match your Production and Development base URL, respectively.
|
42
|
+
* `@routes`: List the routes that you would like to generate the critical CSS for. (i.e. /resources, /resources/show/1, etc.)
|
43
|
+
* `@main_css_path`: Inside of the generate task, you'll need to define the path to the application's main CSS. The gem assumes your CSS lives in `RAILS_ROOT/public`. If your main CSS file is in `RAILS_ROOT/public/assets/main.css`, you would set the variable to `/assets/main.css`.
|
44
|
+
|
45
|
+
|
46
|
+
Before generating the CSS, ensure that your application is running (viewable from a browser) and the main CSS file exists. Then in a separate tab, run the rake task to generate the critical CSS.
|
47
|
+
|
48
|
+
If your main CSS file does not already exist, and you are using the Asset Pipeline, generate the main CSS file.
|
49
|
+
```
|
50
|
+
rake assets:precompile
|
51
|
+
```
|
52
|
+
Generate the critical path CSS:
|
53
|
+
```
|
54
|
+
rake critical_path_css:generate
|
55
|
+
```
|
56
|
+
|
57
|
+
|
58
|
+
To load the generated critical CSS into your layout, in the head tag, insert:
|
59
|
+
|
60
|
+
```html
|
61
|
+
<style>
|
62
|
+
<%= CriticalPathCss.fetch(request.path) %>
|
63
|
+
</style>
|
64
|
+
```
|
65
|
+
|
66
|
+
A simple example using loadcss-rails looks like:
|
67
|
+
|
68
|
+
```html
|
69
|
+
<style>
|
70
|
+
<%= CriticalPathCss.fetch(request.path) %>
|
71
|
+
</style>
|
72
|
+
<script>
|
73
|
+
loadCSS("<%= stylesheet_path('application') %>");
|
74
|
+
</script>
|
75
|
+
<noscript>
|
76
|
+
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
|
77
|
+
</noscript>
|
78
|
+
```
|
79
|
+
|
80
|
+
|
81
|
+
## Versions
|
82
|
+
|
83
|
+
The critical-path-css-rails gem follows these version guidelines:
|
84
|
+
|
85
|
+
```
|
86
|
+
patch version bump = updates to critical-path-css-rails and patch-level updates to Penthouse and PhantomJS
|
87
|
+
minor version bump = minor-level updates to critical-path-css-rails, Penthouse, and PhantomJS
|
88
|
+
major version bump = major-level updates to critical-path-css-rails, Penthouse, PhantomJS, and updates to Rails which may be backwards-incompatible
|
89
|
+
```
|
90
|
+
|
91
|
+
## Contributing
|
92
|
+
|
93
|
+
Feel free to open an issue ticket if you find something that could be improved. A couple notes:
|
94
|
+
|
95
|
+
* If the Penthouse.js script is outdated (i.e. maybe a new version of Penthouse.js was released yesterday), feel free to open an issue and prod us to get that thing updated. However, for security reasons, we won't be accepting pull requests with updated Penthouse.js script.
|
96
|
+
|
97
|
+
Copyright Mudbug Media and Michael Misshore, released under the MIT License.
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require File.expand_path('../lib/critical_path_css/rails/version', __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = 'critical-path-css-rails'
|
5
|
+
s.version = CriticalPathCSS::Rails::VERSION
|
6
|
+
s.platform = Gem::Platform::RUBY
|
7
|
+
s.authors = ['Michael Misshore']
|
8
|
+
s.email = 'mmisshore@gmail.com'
|
9
|
+
s.summary = 'Critical Path CSS for Rails!'
|
10
|
+
s.description = 'Only load the CSS you need for the initial viewport in Rails!'
|
11
|
+
s.license = 'MIT'
|
12
|
+
|
13
|
+
s.add_runtime_dependency 'phantomjs', ['~> 1.9']
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
17
|
+
s.require_path = 'lib'
|
18
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module CriticalPathCss
|
2
|
+
require 'phantomjs'
|
3
|
+
|
4
|
+
CACHE_NAMESPACE = 'critical-path-css'
|
5
|
+
PENTHOUSE_PATH = "#{File.dirname(__FILE__)}/penthouse/penthouse.js"
|
6
|
+
|
7
|
+
def self.generate(main_css_path, base_url, routes)
|
8
|
+
full_main_css_path = "#{Rails.root}/public#{main_css_path}"
|
9
|
+
|
10
|
+
routes.each do |route|
|
11
|
+
css = Phantomjs.run(PENTHOUSE_PATH, base_url + route, full_main_css_path)
|
12
|
+
Rails.cache.write(route, css, namespace: CACHE_NAMESPACE)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.fetch(route)
|
17
|
+
Rails.cache.read(route, namespace: CACHE_NAMESPACE) || ''
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
|
3
|
+
module CriticalPathCss
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
5
|
+
source_root File.expand_path('..', __FILE__)
|
6
|
+
|
7
|
+
# Copy the needed rake task for generating critical CSS
|
8
|
+
def copy_rake_task
|
9
|
+
task_filename = 'critical_path_css.rake'
|
10
|
+
copy_file "../../tasks/#{task_filename}", "lib/tasks/#{task_filename}"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,601 @@
|
|
1
|
+
/*
|
2
|
+
Penthouse CSS Critical Path Generator
|
3
|
+
https://github.com/pocketjoso/penthouse
|
4
|
+
Author: Jonas Ohlsson
|
5
|
+
License: MIT
|
6
|
+
Version: 0.3.4
|
7
|
+
|
8
|
+
USAGE:
|
9
|
+
phantomjs penthouse.js [options] <URL to page> <CSS file>
|
10
|
+
Options:
|
11
|
+
--width <width> The viewport width in pixels. Defaults to 1300
|
12
|
+
--height <height> The viewport height in pixels. Defaults to 900
|
13
|
+
|
14
|
+
to run on HTTPS sites two flags must be passed in, directly after phantomjs in the call:
|
15
|
+
--ignore-ssl-errors=true --ssl-protocol=tlsv1
|
16
|
+
|
17
|
+
DEPENDENCIES
|
18
|
+
+ "phantomjs" : "~1.9.7"
|
19
|
+
|
20
|
+
*/
|
21
|
+
|
22
|
+
|
23
|
+
(function() { "use strict";
|
24
|
+
/*
|
25
|
+
* parser for the script - can be used both for the standalone node binary and the phantomjs script
|
26
|
+
*/
|
27
|
+
|
28
|
+
/*jshint unused:false*/
|
29
|
+
|
30
|
+
var usageString = '[--width <width>] [--height <height>] <url> <main.css>';
|
31
|
+
|
32
|
+
function buildError(msg, problemToken, args) {
|
33
|
+
var error = new Error(msg + problemToken);
|
34
|
+
error.token = problemToken;
|
35
|
+
error.args = args;
|
36
|
+
throw error;
|
37
|
+
}
|
38
|
+
|
39
|
+
// Parses the arguments passed in
|
40
|
+
// @returns { width, height, url, css }
|
41
|
+
// throws an error on wrong options or parsing error
|
42
|
+
function parseOptions(argsOriginal) {
|
43
|
+
var args = argsOriginal.slice(0),
|
44
|
+
validOptions = ['--width', '--height'],
|
45
|
+
parsed = {},
|
46
|
+
val,
|
47
|
+
len = args.length,
|
48
|
+
optIndex,
|
49
|
+
option;
|
50
|
+
|
51
|
+
if (len < 2) buildError('Not enough arguments, ', args);
|
52
|
+
|
53
|
+
while (args.length > 2 && args[0].match(/^(--width|--height)$/)) {
|
54
|
+
optIndex = validOptions.indexOf(args[0]);
|
55
|
+
if (optIndex === -1) buildError('Logic/Parsing error ', args[0], args);
|
56
|
+
|
57
|
+
// lose the dashes
|
58
|
+
option = validOptions[optIndex].slice(2);
|
59
|
+
val = args[1];
|
60
|
+
|
61
|
+
parsed[option] = parseInt(val, 10);
|
62
|
+
if (isNaN(parsed[option])) buildError('Parsing error when parsing ', val, args);
|
63
|
+
|
64
|
+
// remove the two parsed arguments from the list
|
65
|
+
args = args.slice(2);
|
66
|
+
}
|
67
|
+
parsed.url = args[0];
|
68
|
+
parsed.css = args[1];
|
69
|
+
|
70
|
+
if (!parsed.url) {
|
71
|
+
buildError('Missing url/path to html file', '', args);
|
72
|
+
}
|
73
|
+
|
74
|
+
if (!parsed.css) {
|
75
|
+
buildError('Missing css file', '', args);
|
76
|
+
}
|
77
|
+
|
78
|
+
|
79
|
+
return parsed;
|
80
|
+
}
|
81
|
+
|
82
|
+
if (typeof module !== 'undefined') {
|
83
|
+
module.exports = exports = {
|
84
|
+
parse: parseOptions,
|
85
|
+
usage: usageString
|
86
|
+
};
|
87
|
+
}
|
88
|
+
/*
|
89
|
+
module for removing unused fontface rules - can be used both for the standalone node binary and the phantomjs script
|
90
|
+
*/
|
91
|
+
/*jshint unused:false*/
|
92
|
+
|
93
|
+
function unusedFontfaceRemover (css){
|
94
|
+
var toDeleteSections = [];
|
95
|
+
|
96
|
+
//extract full @font-face rules
|
97
|
+
var fontFaceRegex = /(@font-face[ \s\S]*?\{([\s\S]*?)\})/gm,
|
98
|
+
ff;
|
99
|
+
|
100
|
+
while ((ff = fontFaceRegex.exec(css)) !== null) {
|
101
|
+
|
102
|
+
//grab the font name declared in the @font-face rule
|
103
|
+
//(can still be in quotes, f.e. 'Lato Web'
|
104
|
+
var t = /font-family[^:]*?:[ ]*([^;]*)/.exec(ff[1]);
|
105
|
+
if (typeof t[1] === 'undefined')
|
106
|
+
continue; //no font-family in @fontface rule!
|
107
|
+
|
108
|
+
//rm quotes
|
109
|
+
var fontName = t[1].replace(/['"]/gm, '');
|
110
|
+
|
111
|
+
// does this fontname appear as a font-family or font (shorthand) value?
|
112
|
+
var fontNameRegex = new RegExp('([^{}]*?){[^}]*?font(-family)?[^:]*?:[^;]*' + fontName + '[^,;]*[,;]', 'gmi');
|
113
|
+
|
114
|
+
|
115
|
+
var fontFound = false,
|
116
|
+
m;
|
117
|
+
|
118
|
+
while ((m = fontNameRegex.exec(css)) !== null) {
|
119
|
+
if (m[1].indexOf('@font-face') === -1) {
|
120
|
+
//log('FOUND, keep rule');
|
121
|
+
fontFound = true;
|
122
|
+
break;
|
123
|
+
}
|
124
|
+
}
|
125
|
+
if (!fontFound) {
|
126
|
+
//NOT FOUND, rm!
|
127
|
+
|
128
|
+
//can't remove rule here as it will screw up ongoing while (exec ...) loop.
|
129
|
+
//instead: save indices and delete AFTER for loop
|
130
|
+
var closeRuleIndex = css.indexOf('}', ff.index);
|
131
|
+
//unshift - add to beginning of array - we need to remove rules in reverse order,
|
132
|
+
//otherwise indeces will become incorrect again.
|
133
|
+
toDeleteSections.unshift({
|
134
|
+
start: ff.index,
|
135
|
+
end: closeRuleIndex + 1
|
136
|
+
});
|
137
|
+
}
|
138
|
+
}
|
139
|
+
//now delete the @fontface rules we registed as having no matches in the css
|
140
|
+
for (var i = 0; i < toDeleteSections.length; i++) {
|
141
|
+
var start = toDeleteSections[i].start,
|
142
|
+
end = toDeleteSections[i].end;
|
143
|
+
css = css.substring(0, start) + css.substring(end);
|
144
|
+
}
|
145
|
+
|
146
|
+
return css;
|
147
|
+
};
|
148
|
+
|
149
|
+
|
150
|
+
|
151
|
+
if(typeof module !== 'undefined') {
|
152
|
+
module.exports = unusedFontfaceRemover;
|
153
|
+
}
|
154
|
+
/*jshint unused:false*/
|
155
|
+
|
156
|
+
/* === preFormatCSS ===
|
157
|
+
* preformats the css to ensure we won't run into and problems in our parsing
|
158
|
+
* removes comments (actually would be anough to remove/replace {} chars.. TODO
|
159
|
+
* replaces } char inside content: '' properties.
|
160
|
+
*/
|
161
|
+
|
162
|
+
function cssPreformatter (css){
|
163
|
+
//remove comments from css (including multi-line coments)
|
164
|
+
css = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
165
|
+
|
166
|
+
//replace Windows \r\n with \n,
|
167
|
+
//otherwise final output might get converted into /r/r/n
|
168
|
+
css = css.replace(/\r\n/gm, '\n');
|
169
|
+
|
170
|
+
//we also need to replace eventual close curly bracket characters inside content: '' property declarations, replace them with their ASCI code equivalent
|
171
|
+
//\7d (same as: '\' + '}'.charCodeAt(0).toString(16) );
|
172
|
+
|
173
|
+
var m,
|
174
|
+
regexP = /(content\s*:\s*['"][^'"]*)}([^'"]*['"])/gm,
|
175
|
+
matchedData = [];
|
176
|
+
|
177
|
+
//for each content: '' rule that contains at least one end bracket ('}')
|
178
|
+
while ((m = regexP.exec(css)) !== null) {
|
179
|
+
//we need to replace ALL end brackets in the rule
|
180
|
+
//we can't do it in here, because it will mess up ongoing exec, store data and do after
|
181
|
+
|
182
|
+
//unshift - add to beginning of array - we need to remove rules in reverse order,
|
183
|
+
//otherwise indeces will become incorrect.
|
184
|
+
matchedData.unshift({
|
185
|
+
start: m.index,
|
186
|
+
end: m.index + m[0].length,
|
187
|
+
replaceStr: m[0].replace(/\}/gm, '\\7d')
|
188
|
+
});
|
189
|
+
}
|
190
|
+
|
191
|
+
for (var i = 0; i < matchedData.length; i++) {
|
192
|
+
var item = matchedData[0];
|
193
|
+
css = css.substring(0, item.start) + item.replaceStr + css.substring(item.end);
|
194
|
+
}
|
195
|
+
|
196
|
+
return css;
|
197
|
+
};
|
198
|
+
|
199
|
+
if(typeof module !== 'undefined') {
|
200
|
+
module.exports = cssPreformatter;
|
201
|
+
}
|
202
|
+
var standaloneMode = true;
|
203
|
+
'use strict';
|
204
|
+
var standaloneMode = standaloneMode || false;
|
205
|
+
|
206
|
+
var page = require('webpage').create(),
|
207
|
+
fs = require('fs'),
|
208
|
+
system = require('system'),
|
209
|
+
DEBUG = false,
|
210
|
+
stdout = system.stdout; // for using this as a file
|
211
|
+
|
212
|
+
var combineArgsString = function(argsArr) {
|
213
|
+
return [].join.call(argsArr, ' ') + '\n';
|
214
|
+
};
|
215
|
+
|
216
|
+
// monkey patch for directing errors to stderr
|
217
|
+
// https://github.com/ariya/phantomjs/issues/10150#issuecomment-28707859
|
218
|
+
var errorlog = function() {
|
219
|
+
system.stderr.write(combineArgsString(arguments));
|
220
|
+
};
|
221
|
+
|
222
|
+
var debug = function() {
|
223
|
+
if (DEBUG) errorlog('DEBUG: ' + combineArgsString(arguments));
|
224
|
+
};
|
225
|
+
|
226
|
+
// discard stdout from phantom exit;
|
227
|
+
var phantomExit = function(code) {
|
228
|
+
if (page) {
|
229
|
+
page.close();
|
230
|
+
}
|
231
|
+
setTimeout(function() {
|
232
|
+
phantom.exit(code);
|
233
|
+
}, 0);
|
234
|
+
};
|
235
|
+
|
236
|
+
//don't confuse analytics more than necessary when visiting websites
|
237
|
+
page.settings.userAgent = 'Penthouse Critical Path CSS Generator';
|
238
|
+
|
239
|
+
/* prevent page JS errors from being output to final CSS */
|
240
|
+
page.onError = function(msg, trace) {
|
241
|
+
//do nothing
|
242
|
+
};
|
243
|
+
|
244
|
+
page.onResourceError = function(resourceError) {
|
245
|
+
page.reason = resourceError.errorString;
|
246
|
+
page.reason_url = resourceError.url;
|
247
|
+
};
|
248
|
+
|
249
|
+
var main = function(options) {
|
250
|
+
debug('main(): ', JSON.stringify(options));
|
251
|
+
//final cleanup
|
252
|
+
//remove all empty rules, and remove leading/trailing whitespace
|
253
|
+
try {
|
254
|
+
var f = fs.open(options.css, 'r');
|
255
|
+
|
256
|
+
//preformat css
|
257
|
+
var cssPreformat;
|
258
|
+
if (standaloneMode) {
|
259
|
+
cssPreformat = cssPreformatter;
|
260
|
+
} else {
|
261
|
+
cssPreformat = require('./css-preformatter.js');
|
262
|
+
}
|
263
|
+
options.css = cssPreformat(f.read());
|
264
|
+
} catch (e) {
|
265
|
+
errorlog(e);
|
266
|
+
phantomExit(1);
|
267
|
+
}
|
268
|
+
|
269
|
+
// start the critical path CSS generation
|
270
|
+
getCriticalPathCss(options);
|
271
|
+
};
|
272
|
+
|
273
|
+
function cleanup(css) {
|
274
|
+
//remove all animation rules, as keyframes have already been removed
|
275
|
+
css = css.replace(/(-webkit-|-moz-|-ms-|-o-)?animation[ ]?:[^;{}]*;/gm, '');
|
276
|
+
//remove all empty rules, and remove leading/trailing whitespace
|
277
|
+
return css.replace(/[^{}]*\{\s*\}/gm, '').trim();
|
278
|
+
}
|
279
|
+
|
280
|
+
/* Final function
|
281
|
+
* Get's called from getCriticalPathCss when CSS extraction from page is done*/
|
282
|
+
page.onCallback = function(css) {
|
283
|
+
debug('phantom.onCallback');
|
284
|
+
|
285
|
+
try {
|
286
|
+
if (css) {
|
287
|
+
// we are done - clean up the final css
|
288
|
+
var finalCss = cleanup(css);
|
289
|
+
|
290
|
+
// remove unused @fontface rules
|
291
|
+
var ffRemover;
|
292
|
+
if (standaloneMode) {
|
293
|
+
ffRemover = unusedFontfaceRemover;
|
294
|
+
} else {
|
295
|
+
ffRemover = require('./unused-fontface-remover.js');
|
296
|
+
}
|
297
|
+
finalCss = ffRemover(finalCss);
|
298
|
+
|
299
|
+
if(finalCss.trim().length === 0){
|
300
|
+
errorlog('Note: Generated critical css was empty for URL: ' + options.url);
|
301
|
+
}
|
302
|
+
|
303
|
+
// return the critical css!
|
304
|
+
stdout.write(finalCss);
|
305
|
+
phantomExit(0);
|
306
|
+
} else {
|
307
|
+
// No css. This is not an error on our part
|
308
|
+
// but still safer to warn the end user, in case they made a mistake
|
309
|
+
errorlog('Note: Generated critical css was empty for URL: ' + options.url);
|
310
|
+
// for consisteny, still generate output (will be empty)
|
311
|
+
stdout.write(css);
|
312
|
+
phantomExit(0);
|
313
|
+
}
|
314
|
+
|
315
|
+
} catch (ex) {
|
316
|
+
debug('phantom.onCallback -> error', ex);
|
317
|
+
errorlog('error: ' + ex);
|
318
|
+
phantomExit(1);
|
319
|
+
}
|
320
|
+
};
|
321
|
+
|
322
|
+
/*
|
323
|
+
* Tests each selector in css file at specified resolution,
|
324
|
+
* to see if any such elements appears above the fold on the page
|
325
|
+
* modifies CSS - removes selectors that don't appear, and empty rules
|
326
|
+
*
|
327
|
+
* @param options.url the url as a string
|
328
|
+
* @param options.css the css as a string
|
329
|
+
* @param options.width the width of viewport
|
330
|
+
* @param options.height the height of viewport
|
331
|
+
---------------------------------------------------------*/
|
332
|
+
function getCriticalPathCss(options) {
|
333
|
+
debug('getCriticalPathCss():', JSON.stringify(options));
|
334
|
+
|
335
|
+
page.viewportSize = {
|
336
|
+
width: options.width,
|
337
|
+
height: options.height
|
338
|
+
};
|
339
|
+
|
340
|
+
page.open(options.url, function(status) {
|
341
|
+
if (status !== 'success') {
|
342
|
+
errorlog('Error opening url \'' + page.reason_url + '\': ' + page.reason);
|
343
|
+
phantomExit(1);
|
344
|
+
} else {
|
345
|
+
|
346
|
+
debug('Starting sandboxed evaluation of CSS\n', options.css);
|
347
|
+
// sandboxed environments - no outside references
|
348
|
+
// arguments and return value must be primitives
|
349
|
+
// @see http://phantomjs.org/api/webpage/method/evaluate.html
|
350
|
+
page.evaluate(function sandboxed(css) {
|
351
|
+
var h = window.innerHeight,
|
352
|
+
renderWaitTime = 100, //ms TODO: user specifiable through options object
|
353
|
+
finished = false,
|
354
|
+
currIndex = 0,
|
355
|
+
forceRemoveNestedRule = false;
|
356
|
+
|
357
|
+
//split CSS so we can value the (selector) rules separately.
|
358
|
+
//but first, handle stylesheet initial non nested @-rules.
|
359
|
+
//they don't come with any associated rules, and should all be kept,
|
360
|
+
//so just keep them in critical css, but don't include them in split
|
361
|
+
var splitCSS = css.replace(/@(import|charset|namespace)[^;]*;/g, '');
|
362
|
+
var split = splitCSS.split(/[{}]/g);
|
363
|
+
|
364
|
+
var getNewValidCssSelector = function(i) {
|
365
|
+
var newSel = split[i];
|
366
|
+
/* HANDLE Nested @-rules */
|
367
|
+
|
368
|
+
/*Case 1: @-rule with CSS properties inside [REMAIN]
|
369
|
+
Can't remove @font-face rules here, don't know if used or not.
|
370
|
+
Another check at end for this purpose.
|
371
|
+
*/
|
372
|
+
if (/@(font-face)/gi.test(newSel)) {
|
373
|
+
//skip over this rule
|
374
|
+
currIndex = css.indexOf('}', currIndex) + 1;
|
375
|
+
return getNewValidCssSelector(i + 2);
|
376
|
+
}
|
377
|
+
/*Case 2: @-rule with CSS properties inside [REMOVE]
|
378
|
+
@page
|
379
|
+
This case doesn't need any special handling,
|
380
|
+
as this "selector" won't match anything on the page,
|
381
|
+
and will therefor be removed, together with it's css props
|
382
|
+
*/
|
383
|
+
|
384
|
+
/*Case 4: @-rule with full CSS (rules) inside [REMOVE]
|
385
|
+
@media print|speech|aural, @keyframes
|
386
|
+
Delete this rule and all its contents - doesn't belong in critical path CSS
|
387
|
+
*/
|
388
|
+
else if (/@(media (print|speech|aural)|(([a-z\-])*keyframes))/gi.test(newSel)) {
|
389
|
+
//force delete on child css rules
|
390
|
+
forceRemoveNestedRule = true;
|
391
|
+
return getNewValidCssSelector(i + 1);
|
392
|
+
}
|
393
|
+
|
394
|
+
/*Case 3: @-rule with full CSS (rules) inside [REMAIN]
|
395
|
+
This test is executed AFTER Case 4,
|
396
|
+
since we here match every remaining @media,
|
397
|
+
after @media print has been removed by Case 4 rule)
|
398
|
+
- just skip this particular line (i.e. keep), and continue checking the CSS inside as normal
|
399
|
+
*/
|
400
|
+
else if (/@(media|(-moz-)?document|supports)/gi.test(newSel)) {
|
401
|
+
return getNewValidCssSelector(i + 1);
|
402
|
+
}
|
403
|
+
/*
|
404
|
+
Resume normal execution after end of @-media rule with inside CSS rules (Case 3)
|
405
|
+
Also identify abrupt file end.
|
406
|
+
*/
|
407
|
+
else if (newSel.trim().length === 0) {
|
408
|
+
//abrupt file end
|
409
|
+
if (i + 1 >= split.length) {
|
410
|
+
//end of file
|
411
|
+
finished = true;
|
412
|
+
return false;
|
413
|
+
}
|
414
|
+
//end of @-rule (Case 3)
|
415
|
+
forceRemoveNestedRule = false;
|
416
|
+
return getNewValidCssSelector(i + 1);
|
417
|
+
}
|
418
|
+
return i;
|
419
|
+
};
|
420
|
+
|
421
|
+
var removeSelector = function(sel, selectorsKept) {
|
422
|
+
var selPos = css.indexOf(sel, currIndex);
|
423
|
+
|
424
|
+
//check what comes next: { or ,
|
425
|
+
var nextComma = css.indexOf(',', selPos);
|
426
|
+
var nextOpenBracket = css.indexOf('{', selPos);
|
427
|
+
|
428
|
+
if (selectorsKept > 0 || (nextComma > 0 && nextComma < nextOpenBracket)) {
|
429
|
+
//we already kept selectors from this rule, so rule will stay
|
430
|
+
|
431
|
+
//more selectors in selectorList, cut until (and including) next comma
|
432
|
+
if (nextComma > 0 && nextComma < nextOpenBracket) {
|
433
|
+
css = css.substring(0, selPos) + css.substring(nextComma + 1);
|
434
|
+
}
|
435
|
+
//final selector, cut until open bracket. Also remove previous comma, as the (new) last selector should not be followed by a comma.
|
436
|
+
else {
|
437
|
+
var prevComma = css.lastIndexOf(',', selPos);
|
438
|
+
css = css.substring(0, prevComma) + css.substring(nextOpenBracket);
|
439
|
+
}
|
440
|
+
} else {
|
441
|
+
//no part of selector (list) matched elements above fold on page - remove whole rule CSS rule
|
442
|
+
var endRuleBracket = css.indexOf('}', nextOpenBracket);
|
443
|
+
|
444
|
+
css = css.substring(0, selPos) + css.substring(endRuleBracket + 1);
|
445
|
+
}
|
446
|
+
};
|
447
|
+
|
448
|
+
|
449
|
+
var processCssRules = function() {
|
450
|
+
for (var i = 0; i < split.length; i = i + 2) {
|
451
|
+
//step over non DOM CSS selectors (@-rules)
|
452
|
+
i = getNewValidCssSelector(i);
|
453
|
+
|
454
|
+
//reach end of CSS
|
455
|
+
if (finished) {
|
456
|
+
//call final function to exit outside of phantom evaluate scope
|
457
|
+
window.callPhantom(css);
|
458
|
+
}
|
459
|
+
|
460
|
+
var fullSel = split[i];
|
461
|
+
//fullSel can contain combined selectors
|
462
|
+
//,f.e. body, html {}
|
463
|
+
//split and check one such selector at the time.
|
464
|
+
var selSplit = fullSel.split(',');
|
465
|
+
//keep track - if we remove all selectors, we also want to remove the whole rule.
|
466
|
+
var selectorsKept = 0;
|
467
|
+
var aboveFold;
|
468
|
+
|
469
|
+
for (var j = 0; j < selSplit.length; j++) {
|
470
|
+
var sel = selSplit[j];
|
471
|
+
|
472
|
+
//some selectors can't be matched on page.
|
473
|
+
//In these cases we test a slightly modified selectors instead, temp.
|
474
|
+
var temp = sel;
|
475
|
+
|
476
|
+
if (sel.indexOf(':') > -1) {
|
477
|
+
//handle special case selectors, the ones that contain a semi colon (:)
|
478
|
+
//many of these selectors can't be matched to anything on page via JS,
|
479
|
+
//but that still might affect the above the fold styling
|
480
|
+
|
481
|
+
//these psuedo selectors depend on an element,
|
482
|
+
//so test element instead (would do the same for f.e. :hover, :focus, :active IF we wanted to keep them for critical path css, but we don't)
|
483
|
+
temp = temp.replace(/(:?:before|:?:after)*/g, '');
|
484
|
+
|
485
|
+
//if selector is purely psuedo (f.e. ::-moz-placeholder), just keep as is.
|
486
|
+
//we can't match it to anything on page, but it can impact above the fold styles
|
487
|
+
if (temp.replace(/:[:]?([a-zA-Z0-9\-\_])*/g, '').trim().length === 0) {
|
488
|
+
currIndex = css.indexOf(sel, currIndex) + sel.length;
|
489
|
+
selectorsKept++;
|
490
|
+
continue;
|
491
|
+
}
|
492
|
+
|
493
|
+
//handle browser specific psuedo selectors bound to elements,
|
494
|
+
//Example, button::-moz-focus-inner, input[type=number]::-webkit-inner-spin-button
|
495
|
+
//remove browser specific pseudo and test for element
|
496
|
+
temp = temp.replace(/:?:-[a-z-]*/g, '');
|
497
|
+
}
|
498
|
+
|
499
|
+
if (!forceRemoveNestedRule) {
|
500
|
+
//now we have a selector to test, first grab any matching elements
|
501
|
+
var el;
|
502
|
+
try {
|
503
|
+
el = document.querySelectorAll(temp);
|
504
|
+
} catch (e) {
|
505
|
+
//not a valid selector, remove it.
|
506
|
+
removeSelector(sel, 0);
|
507
|
+
continue;
|
508
|
+
}
|
509
|
+
|
510
|
+
//check if selector matched element(s) on page..
|
511
|
+
aboveFold = false;
|
512
|
+
|
513
|
+
for (var k = 0; k < el.length; k++) {
|
514
|
+
var testEl = el[k];
|
515
|
+
//temporarily force clear none in order to catch elements that clear previous content themselves and who w/o their styles could show up unstyled in above the fold content (if they rely on f.e. 'clear:both;' to clear some main content)
|
516
|
+
testEl.style.clear = 'none';
|
517
|
+
|
518
|
+
//check to see if any matched element is above the fold on current page
|
519
|
+
//(in current viewport size)
|
520
|
+
if (testEl.getBoundingClientRect().top < h) {
|
521
|
+
//then we will save this selector
|
522
|
+
aboveFold = true;
|
523
|
+
selectorsKept++;
|
524
|
+
|
525
|
+
//update currIndex so we only search from this point from here on.
|
526
|
+
currIndex = css.indexOf(sel, currIndex);
|
527
|
+
|
528
|
+
//set clear style back to what it was
|
529
|
+
testEl.style.clear = '';
|
530
|
+
//break, because matching 1 element is enough
|
531
|
+
break;
|
532
|
+
}
|
533
|
+
//set clear style back to what it was
|
534
|
+
testEl.style.clear = '';
|
535
|
+
}
|
536
|
+
} else {
|
537
|
+
aboveFold = false;
|
538
|
+
} //force removal of selector
|
539
|
+
|
540
|
+
//if selector didn't match any elements above fold - delete selector from CSS
|
541
|
+
if (aboveFold === false) {
|
542
|
+
//update currIndex so we only search from this point from here on.
|
543
|
+
currIndex = css.indexOf(sel, currIndex);
|
544
|
+
//remove seletor (also removes rule, if nnothing left)
|
545
|
+
removeSelector(sel, selectorsKept);
|
546
|
+
}
|
547
|
+
}
|
548
|
+
//if rule stayed, move our cursor forward for matching new selectors
|
549
|
+
if (selectorsKept > 0) {
|
550
|
+
currIndex = css.indexOf('}', currIndex) + 1;
|
551
|
+
}
|
552
|
+
}
|
553
|
+
|
554
|
+
//we're done - call final function to exit outside of phantom evaluate scope
|
555
|
+
window.callPhantom(css);
|
556
|
+
};
|
557
|
+
|
558
|
+
//give some time (renderWaitTime) for sites like facebook that build their page dynamically,
|
559
|
+
//otherwise we can miss some selectors (and therefor rules)
|
560
|
+
//--tradeoff here: if site is too slow with dynamic content,
|
561
|
+
// it doesn't deserve to be in critical path.
|
562
|
+
setTimeout(processCssRules, renderWaitTime);
|
563
|
+
|
564
|
+
}, options.css);
|
565
|
+
}
|
566
|
+
});
|
567
|
+
}
|
568
|
+
|
569
|
+
var parser, parse, usage, options;
|
570
|
+
|
571
|
+
// test to see if we are running as a standalone script
|
572
|
+
// or as part of the node module
|
573
|
+
if (standaloneMode) {
|
574
|
+
parse = parseOptions;
|
575
|
+
usage = usageString;
|
576
|
+
} else {
|
577
|
+
parser = require('../options-parser');
|
578
|
+
parse = parser.parse;
|
579
|
+
usage = parser.usage;
|
580
|
+
}
|
581
|
+
|
582
|
+
try {
|
583
|
+
options = parse(system.args.slice(1));
|
584
|
+
} catch (ex) {
|
585
|
+
|
586
|
+
errorlog('Caught error parsing arguments: ' + ex.message);
|
587
|
+
|
588
|
+
// the usage string does not make sense to show if running via Node
|
589
|
+
if(standaloneMode) {
|
590
|
+
errorlog('\nUsage: phantomjs penthouse.js ' + usage);
|
591
|
+
}
|
592
|
+
|
593
|
+
phantomExit(1);
|
594
|
+
}
|
595
|
+
|
596
|
+
// set defaults
|
597
|
+
if (!options.width) options.width = 1300;
|
598
|
+
if (!options.height) options.height = 900;
|
599
|
+
|
600
|
+
main(options);
|
601
|
+
})();
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'critical_path_css_rails'
|
2
|
+
|
3
|
+
namespace :critical_path_css do
|
4
|
+
@base_url = Rails.env.production? ? 'http://example.com' : 'http://localhost:3000'
|
5
|
+
@routes = %w(
|
6
|
+
/
|
7
|
+
)
|
8
|
+
|
9
|
+
desc 'Generate critical CSS for the routes defined'
|
10
|
+
task generate: :environment do
|
11
|
+
@main_css_path = ActionController::Base.helpers.stylesheet_path('application.css').to_s
|
12
|
+
|
13
|
+
CriticalPathCss.generate(@main_css_path, @base_url, @routes)
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: critical-path-css-rails
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael Misshore
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-09-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: phantomjs
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.9'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.9'
|
27
|
+
description: Only load the CSS you need for the initial viewport in Rails!
|
28
|
+
email: mmisshore@gmail.com
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- ".gitignore"
|
34
|
+
- ".rubocop.yml"
|
35
|
+
- ".rubocop_todo.yml"
|
36
|
+
- BACKLOG.md
|
37
|
+
- Gemfile
|
38
|
+
- LICENSE
|
39
|
+
- README.md
|
40
|
+
- critical-path-css-rails.gemspec
|
41
|
+
- lib/critical_path_css/rails/engine.rb
|
42
|
+
- lib/critical_path_css/rails/version.rb
|
43
|
+
- lib/critical_path_css_rails.rb
|
44
|
+
- lib/generators/critical_path_css/install_generator.rb
|
45
|
+
- lib/penthouse/penthouse.js
|
46
|
+
- lib/tasks/critical_path_css.rake
|
47
|
+
homepage:
|
48
|
+
licenses:
|
49
|
+
- MIT
|
50
|
+
metadata: {}
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
require_paths:
|
54
|
+
- lib
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
requirements: []
|
66
|
+
rubyforge_project:
|
67
|
+
rubygems_version: 2.4.8
|
68
|
+
signing_key:
|
69
|
+
specification_version: 4
|
70
|
+
summary: Critical Path CSS for Rails!
|
71
|
+
test_files: []
|