Kaynağa Gözat

File minification

Gaël Métais 10 yıl önce
ebeveyn
işleme
c1bae20f01

+ 1 - 1
front/src/js/directives/offendersDirectives.js

@@ -865,7 +865,7 @@
             var kilo = bytes / 1024;
 
             if (kilo < 1) {
-                return bytes + ' Bytes';
+                return bytes + ' bytes';
             }
 
             if (kilo < 100) {

+ 22 - 2
front/src/views/rule.html

@@ -137,9 +137,9 @@
                         <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
                     </div>
 
-                    <div ng-if="policyName === 'requests' || policyName === 'htmlCount' || policyName === 'jsCount' || policyName === 'cssCount' || policyName === 'imageCount' || policyName === 'webfontCount' || policyName === 'videoCount' || policyName === 'jsonCount' || policyName === 'otherCount' || policyName === 'smallJsFiles' || policyName === 'smallCssFiles' || policyName === 'smallImages'">
+                    <div ng-if="policyName === 'requests' || policyName === 'smallJsFiles' || policyName === 'smallCssFiles' || policyName === 'smallImages'">
                         <url-link url="offender.file" max-length="100"></url-link>
-                        <span ng-if="offender.size || offender.size === 0">({{offender.size}} kB)</span>
+                        <span ng-if="offender.size || offender.size === 0">({{offender.size}} KB)</span>
                     </div>
 
                     <div ng-if="policyName === 'notFound' || policyName === 'closedConnections' || policyName === 'multipleRequests' || policyName === 'cachingDisabled' || policyName === 'cachingNotSpecified'">
@@ -230,6 +230,26 @@
         </div>
     </div>
 
+    <div ng-if="policyName === 'fileMinification'">
+        <h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.files.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
+        <div class="table">
+            <div class="headers">
+                <div>File</div>
+                <div>Current weight</div>
+                <div>Minified</div>
+                <div>Gain</div>
+            </div>
+            <div ng-repeat="file in rule.offendersObj.list.files | orderBy:'-gain'">
+                <div>
+                    <url-link url="file.url" max-length="60"></url-link>
+                </div>
+                <div>{{file.original | bytes}}</div>
+                <div>{{file.minified | bytes}}</div>
+                <div><b>-{{file.gain | bytes}}</b></div>
+            </div>
+        </div>
+    </div>
+
     <div ng-if="policyName === 'DOMaccesses'">
         <h3>{{rule.value}} offenders</h3>
         Please open the <a href="/result/{{runId}}/timeline">JS timeline</a>

+ 11 - 1
lib/metadata/policies.js

@@ -872,6 +872,16 @@ var policies = {
         "hasOffenders": true,
         "unit": 'bytes'
     },
+    "fileMinification": {
+        "tool": "weightChecker",
+        "label": "File minification",
+        "message": "<p>This is the weight that could be saved if all text resources were correctly minified.</p><p>The tools in use here are <b>UglifyJS</b>, <b>clean-css</b> and <b>HTMLMinifier</b>.</p><p>The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.</p>",
+        "isOkThreshold": 20480,
+        "isBadThreshold": 81920,
+        "isAbnormalThreshold": 153600,
+        "hasOffenders": true,
+        "unit": 'bytes'
+    },
     "requests": {
         "tool": "phantomas",
         "label": "Total requests number",
@@ -888,7 +898,7 @@ var policies = {
                     .map(function(offender) {
                         return offendersHelpers.fileWithSizePattern(offender);
                     }).sort(function(a, b) {
-                        return b.size - a.size;
+                        return (b.file < a.file) ? 1 : (b.file > a.file) ? -1 : 0;
                     })
             };
         }

+ 3 - 1
lib/metadata/scoreProfileGeneric.json

@@ -73,7 +73,9 @@
             "label": "Page weight",
             "policies": {
                 "totalWeight": 5,
-                "imageOptimization": 3
+                "imageOptimization": 2,
+                "fileMinification": 1,
+                "assetsNotGzipped": 2
             }
         },
         "requests": {

+ 195 - 0
lib/tools/weightChecker/fileMinifier.js

@@ -0,0 +1,195 @@
+var debug = require('debug')('ylt:fileMinifier');
+
+var Q               = require('q');
+var UglifyJS        = require('uglify-js');
+var CleanCSS        = require('clean-css');
+var Minimize        = require('minimize');
+
+
+var FileMinifier = function() {
+
+    function minifyFile(entry) {
+        var deferred = Q.defer();
+
+        if (!entry.weightCheck || !entry.weightCheck.body) {
+            // No valid file available
+            deferred.resolve(entry);
+            return deferred.promise;
+        }
+
+        var fileSize = entry.weightCheck.uncompressedSize;
+        debug('Let\'s try to optimize %s', entry.url);
+        debug('Current file size is %d', fileSize);
+
+        if (entry.isJS) {
+
+            debug('File is a JS');
+
+            // Starting softly with a lossless compression
+            return minifyJs(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.length;
+
+                debug('JS minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.minified = newFileSize;
+                    entry.weightCheck.isMinified = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                return entry;
+            });
+
+        } else if (entry.isCSS) {
+
+            debug('File is a CSS');
+
+            // Starting softly with a lossless compression
+            return minifyCss(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.length;
+
+                debug('CSS minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.minified = newFileSize;
+                    entry.weightCheck.isMinified = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                return entry;
+            });
+
+        } else if (entry.isHTML) {
+
+            debug('File is an HTML');
+
+            // Starting softly with a lossless compression
+            return minifyHtml(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                console.log('KKKKKKKKKKKK');
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.length;
+
+                debug('HTML minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.minified = newFileSize;
+                    entry.weightCheck.isMinified = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                } else {
+                    console.log('OOOO old file size: ' + fileSize);
+                    console.log('OOOO new file size: ' + newFileSize);
+                    console.log(entry.weightCheck);
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                console.log('LLLLLLLLLLLLLLL');
+                console.log(err);
+                return entry;
+            });
+
+        } else {
+            debug('File type %s is not an (optimizable) image', entry.contentType);
+            deferred.resolve(entry);
+        }
+
+        return deferred.promise;
+    }
+
+    // The gain is estimated of enough value if it's over 2KB or over 20%,
+    // but it's ignored if is below 100 bytes
+    function gainIsEnough(oldWeight, newWeight) {
+        var gain = oldWeight - newWeight;
+        var ratio = gain / oldWeight;
+        return (gain > 2096 || (ratio > 0.2 && gain > 400));
+    }
+
+    // Uglify
+    function minifyJs(body) {
+        var deferred = Q.defer();
+
+        try {
+            var result = UglifyJS.minify(body, {fromString: true});
+            deferred.resolve(result.code);
+        } catch(err) {
+            deferred.reject(err);
+        }
+
+        return deferred.promise;
+    }
+
+    // Clear-css
+    function minifyCss(body) {
+        var deferred = Q.defer();
+
+        try {
+            var result = new CleanCSS({compatibility: 'ie8'}).minify(body);
+            deferred.resolve(result.styles);
+        } catch(err) {
+            deferred.reject(err);
+        }
+
+        return deferred.promise;
+    }
+
+    // HTMLMinifier
+    function minifyHtml(body) {
+        var deferred = Q.defer();
+
+        var minimize = new Minimize({
+            empty: true,        // KEEP empty attributes
+            conditionals: true, // KEEP conditional internet explorer comments
+            spare: true         // KEEP redundant attributes
+        });
+
+        minimize.parse(body, function (error, data) {
+            if (error) {
+                deferred.reject(error);
+            } else {
+                deferred.resolve(data);
+            }
+        });
+
+        return deferred.promise;
+    }
+
+    return {
+        minifyFile: minifyFile,
+        minifyJs: minifyJs,
+        minifyCss: minifyCss,
+        minifyHtml: minifyHtml,
+        gainIsEnough: gainIsEnough
+    };
+};
+
+module.exports = new FileMinifier();

+ 3 - 4
lib/tools/weightChecker/imageOptimizer.js

@@ -7,18 +7,18 @@ var jpegoptim   = require('imagemin-jpegoptim');
 var ImageOptimizer = function() {
 
     var MAX_JPEG_QUALITY = 85;
-    var OPTIPNG_COMPRESSION_LEVEL = 2;
+    var OPTIPNG_COMPRESSION_LEVEL = 1;
 
     function optimizeImage(entry) {
         var deferred = Q.defer();
 
-        if (!entry.weightCheck.body) {
+        if (!entry.weightCheck || !entry.weightCheck.body) {
             // No valid file available
             deferred.resolve(entry);
             return deferred.promise;
         }
 
-        var fileSize = entry.weightCheck.bodySize;
+        var fileSize = entry.weightCheck.uncompressedSize;
         debug('Let\'s try to optimize %s', entry.url);
         debug('Current file size is %d', fileSize);
 
@@ -227,7 +227,6 @@ var ImageOptimizer = function() {
     }
 
     return {
-        recompressIfImage: optimizeImage,
         optimizeImage: optimizeImage,
         compressJpegLosslessly: compressJpegLosslessly,
         compressJpegLossly: compressJpegLossly,

+ 68 - 12
lib/tools/weightChecker/weightChecker.js

@@ -13,6 +13,7 @@ var async           = require('async');
 var request         = require('request');
 
 var imageOptimizer  = require('./imageOptimizer');
+var fileMinifier    = require('./fileMinifier');
 
 
 var WeightChecker = function() {
@@ -23,6 +24,8 @@ var WeightChecker = function() {
 
     // This function will re-download every asset and check if it could be optimized
     function recheckAllFiles(data) {
+        var startTime = Date.now();
+        debug('Redownload started');
         var deferred = Q.defer();
 
         var requestsList = JSON.parse(data.toolsResults.phantomas.offenders.requestsList);
@@ -35,7 +38,9 @@ var WeightChecker = function() {
                 
                 redownloadEntry(entry)
 
-                .then(imageOptimizer.recompressIfImage)
+                .then(imageOptimizer.optimizeImage)
+
+                .then(fileMinifier.minifyFile)
 
                 .then(function(newEntry) {
                     callback(null, newEntry);
@@ -49,11 +54,20 @@ var WeightChecker = function() {
 
         // Lanch all redownload functions and wait for completion
         async.parallelLimit(redownloadList, MAX_PARALLEL_DOWNLOADS, function(err, results) {
+            
             if (err) {
                 debug(err);
                 deferred.reject(err);
             } else {
+
                 debug('All files checked');
+                endTime = Date.now();
+                debug('Redownload took %d ms', endTime - startTime);
+                
+                // Remove unwanted requests (redirections, about:blank)
+                results = results.filter(function(result) {
+                    return (result !== null && result.weightCheck && result.weightCheck.bodySize > 0);
+                });
                 
                 var metrics = {};
                 var offenders = {};
@@ -67,6 +81,11 @@ var WeightChecker = function() {
                 offenders.imageOptimization = listImageNotOptimized(results);
                 metrics.imageOptimization = offenders.imageOptimization.totalGain;
 
+                // File minification
+                offenders.fileMinification = listFilesNotMinified(results);
+                metrics.fileMinification = offenders.fileMinification.totalGain;
+
+                
 
                 data.toolsResults.weightChecker = {
                     metrics: metrics,
@@ -144,14 +163,14 @@ var WeightChecker = function() {
         };
 
         requests.forEach(function(req) {
-            if (req.weightCheck.bodySize && req.weightCheck.isOptimized === false) {
-                var gain = req.weightCheck.bodySize - req.weightCheck.optimized;
+            if (req.weightCheck.uncompressedSize && req.weightCheck.isOptimized === false) {
+                var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized;
 
                 results.totalGain += gain;
 
                 results.images.push({
                     url: req.url,
-                    original: req.weightCheck.bodySize,
+                    original: req.weightCheck.uncompressedSize,
                     optimized: req.weightCheck.optimized,
                     lossless: req.weightCheck.lossless,
                     lossy: req.weightCheck.lossy,
@@ -164,10 +183,35 @@ var WeightChecker = function() {
     }
 
 
+    function listFilesNotMinified(requests) {
+        var results = {
+            totalGain: 0,
+            files: []
+        };
+
+        requests.forEach(function(req) {
+            if (req.weightCheck.uncompressedSize && req.weightCheck.isMinified === false) {
+                var gain = req.weightCheck.uncompressedSize - req.weightCheck.minified;
+
+                results.totalGain += gain;
+
+                results.files.push({
+                    url: req.url,
+                    original: req.weightCheck.uncompressedSize,
+                    minified: req.weightCheck.minified,
+                    gain: gain
+                });
+            }
+        });
+
+        return results;
+    }
+
+
     function redownloadEntry(entry) {
         var deferred = Q.defer();
         
-        function onError(message) {
+        function downloadError(message) {
             debug('Could not download %s Error: %s', entry.url, message);
             entry.weightCheck = {
                 message: message
@@ -175,18 +219,32 @@ var WeightChecker = function() {
             deferred.resolve(entry);
         }
 
+        // Not downloaded again but will be counted in totalWeight
+        function notDownloadableFile(message) {
+            entry.weightCheck = {
+                message: message
+            };
+            deferred.resolve(entry);
+        }
+
+        // Not counted in totalWeight
+        function unwantedFile(message) {
+            debug(message);
+            deferred.resolve(entry);
+        }
+
         if (entry.method !== 'GET') {
-            onError('only downloading GET');
+            notDownloadableFile('only downloading GET');
             return deferred.promise;
         }
 
         if (entry.status !== 200) {
-            onError('only downloading requests with status code 200');
+            unwantedFile('only downloading requests with status code 200');
             return deferred.promise;
         }
 
         if (entry.url === 'about:blank') {
-            onError('not downloading about:blank');
+            unwantedFile('not downloading about:blank');
             return deferred.promise;
         }
 
@@ -202,15 +260,14 @@ var WeightChecker = function() {
             url: entry.url,
             headers: reqHeaders,
             timeout: REQUEST_TIMEOUT
-            //encoding: (entry.contentType === 'image/jpeg' || entry.contentType === 'image/png') ? 'binary' : null
         };
 
         download(requestOptions, entry.contentType, function(error, result) {
             if (error) {
                 if (error.code === 'ETIMEDOUT') {
-                    onError('timeout after ' + REQUEST_TIMEOUT + 'ms');
+                    downloadError('timeout after ' + REQUEST_TIMEOUT + 'ms');
                 } else {
-                    onError('error while downloading: ' + error.code);
+                    downloadError('error while downloading: ' + error.code);
                 }
                 return;
             }
@@ -332,7 +389,6 @@ var WeightChecker = function() {
         });
     }
 
-
     return {
         recheckAllFiles: recheckAllFiles,
         listRequestWeight: listRequestWeight,

+ 15 - 12
package.json

@@ -11,21 +11,24 @@
   },
   "main": "./lib/index.js",
   "dependencies": {
-    "async": "~1.0.0",
-    "body-parser": "~1.12.4",
-    "compression": "~1.4.4",
-    "cors": "^2.6.0",
-    "debug": "~2.2.0",
-    "express": "~4.12.4",
-    "imagemin": "~3.2.0",
-    "imagemin-jpegoptim": "~4.0.0",
+    "async": "1.0.0",
+    "body-parser": "1.12.4",
+    "clean-css": "3.3.0",
+    "compression": "1.4.4",
+    "cors": "2.6.0",
+    "debug": "2.2.0",
+    "express": "4.12.4",
+    "imagemin": "3.2.0",
+    "imagemin-jpegoptim": "4.0.0",
     "lwip": "0.0.6",
-    "meow": "^3.1.0",
+    "meow": "3.1.0",
+    "minimize": "1.4.1",
     "phantomas": "1.10.2",
     "ps-node": "0.0.4",
-    "q": "~1.4.1",
-    "rimraf": "~2.3.4",
-    "temporary": "0.0.8"
+    "q": "1.4.1",
+    "rimraf": "2.3.4",
+    "temporary": "0.0.8",
+    "uglify-js": "2.4.23"
   },
   "devDependencies": {
     "chai": "^2.3.0",

+ 214 - 0
test/core/fileMinifierTest.js

@@ -0,0 +1,214 @@
+var should = require('chai').should();
+var fileMinifier = require('../../lib/tools/weightChecker/fileMinifier');
+var fs = require('fs');
+var path = require('path');
+
+describe('fileMinifier', function() {
+    
+    it('should minify a JS file with minifyJs', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-script.js'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyJs(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a JS file with minifyFile', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-script.js'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-script.js',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isMinified').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail minifying an already minified JS', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jquery1.8.3.js'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/jquery1.8.3.js',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isMinified');
+            newEntry.weightCheck.should.not.have.a.property('minified');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail minifying a JS with syntax errors', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/svg-image.svg',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isMinified');
+            newEntry.weightCheck.should.not.have.a.property('minified');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a CSS file with clean-css', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-stylesheet.css'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyCss(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a CSS file with minifyFile', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-stylesheet.css'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-stylesheet.css',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isCSS: true,
+            type: 'css',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isMinified').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify an HTML file with ', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jquery-page.html'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyHtml(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+});

+ 32 - 4
test/core/weightCheckerTest.js

@@ -57,6 +57,30 @@ describe('weightChecker', function() {
                 type: 'image',
                 contentType: 'image/svg+xml'
             },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/unminified-script.js',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isJS: true,
+                type: 'js'
+            },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/unminified-stylesheet.css',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isCSS: true,
+                type: 'css'
+            },
             {
                 method: 'GET',
                 url: 'about:blank',
@@ -92,14 +116,19 @@ describe('weightChecker', function() {
             data.toolsResults.weightChecker.offenders.should.have.a.property('totalWeight');
             data.toolsResults.weightChecker.offenders.totalWeight.totalWeight.should.be.above(0);
             data.toolsResults.weightChecker.offenders.totalWeight.byType.html.requests.length.should.equal(1);
-            data.toolsResults.weightChecker.offenders.totalWeight.byType.js.requests.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.js.requests.length.should.equal(2);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.css.requests.length.should.equal(1);
             data.toolsResults.weightChecker.offenders.totalWeight.byType.image.requests.length.should.equal(2);
-            data.toolsResults.weightChecker.offenders.totalWeight.byType.other.requests.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.other.requests.length.should.equal(0);
 
             data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization');
             data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0);
             data.toolsResults.weightChecker.offenders.imageOptimization.images.length.should.equal(2);
 
+            data.toolsResults.weightChecker.offenders.should.have.a.property('fileMinification');
+            data.toolsResults.weightChecker.offenders.fileMinification.totalGain.should.be.above(0);
+            data.toolsResults.weightChecker.offenders.fileMinification.files.length.should.equal(2);
+
             done();
         })
 
@@ -222,8 +251,7 @@ describe('weightChecker', function() {
         weightChecker.redownloadEntry(entry)
 
         .then(function(newEntry) {
-            newEntry.weightCheck.should.have.a.property('message').that.equals('only downloading requests with status code 200');
-
+            newEntry.should.not.have.a.property('weightCheck');
             done();
         })
 

+ 190 - 0
test/www/unminified-script.js

@@ -0,0 +1,190 @@
+var timelineCtrl = angular.module('timelineCtrl', []);
+
+timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, API) {
+    $scope.runId = $routeParams.runId;
+    $scope.Menu = Menu.setCurrentPage('timeline', $scope.runId);
+
+    function loadResults() {
+        // Load result if needed
+        if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
+            Results.get({runId: $routeParams.runId, exclude: 'toolsResults'}, function(result) {
+                $rootScope.loadedResult = result;
+                $scope.result = result;
+                render();
+            });
+        } else {
+            $scope.result = $rootScope.loadedResult;
+            render();
+        }
+    }
+
+    function render() {
+        initFilters();
+        initScriptFiltering();
+        initExecutionTree();
+        initTimeline();
+        $timeout(initProfiler, 100);
+    }
+
+    function initFilters() {
+        var hash = $location.hash();
+        var filter = null;
+        
+        if (hash.indexOf('filter=') === 0) {
+            filter = hash.substr(7);
+        }
+
+        $scope.warningsFilterOn = (filter !== null);
+        $scope.warningsFilters = {
+            queryWithoutResults: (filter === null || filter === 'queryWithoutResults'),
+            jQueryCallOnEmptyObject: (filter === null || filter === 'jQueryCallOnEmptyObject'),
+            eventNotDelegated: (filter === null || filter === 'eventNotDelegated'),
+            jsError: (filter === null || filter === 'jsError')
+        };
+    }
+
+    function initScriptFiltering() {
+        var offenders = $scope.result.rules.jsCount.offendersObj.list;
+        $scope.scripts = [];
+
+        offenders.forEach(function(script) {
+            var filePath = script.file;
+
+            if (filePath.length > 100) {
+                filePath = filePath.substr(0, 98) + '...';
+            }
+
+            var scriptObj = {
+                fullPath: script.file, 
+                shortPath: filePath
+            };
+
+            $scope.scripts.push(scriptObj);
+        });
+    }
+
+    function initExecutionTree() {
+        var originalExecutions = $scope.result.javascriptExecutionTree.children || [];
+        
+        // Detect the last event of all (before filtering) and read time
+        var lastEvent = originalExecutions[originalExecutions.length - 1];
+        $scope.endTime =  lastEvent.data.timestamp + (lastEvent.data.time || 0);
+
+        // Filter
+        $scope.executionTree = [];
+        originalExecutions.forEach(function(node) {
+            
+            // Filter by script (if enabled)
+            if ($scope.selectedScript) {
+                if (node.data.backtrace && node.data.backtrace.indexOf($scope.selectedScript.fullPath + ':') === -1) {
+                    return;
+                }
+                if (node.data.type === "jQuery loaded" || node.data.type === "jQuery version change") {
+                    return;
+                }
+            }
+
+            $scope.executionTree.push(node);
+        });
+    }
+
+    function initTimeline() {
+
+        // Split the timeline into 200 intervals
+        var numberOfIntervals = 199;
+        $scope.timelineIntervalDuration = $scope.endTime / numberOfIntervals;
+        
+        // Pre-fill array of as many elements as there are milleseconds
+        var millisecondsArray = Array.apply(null, new Array($scope.endTime + 1)).map(Number.prototype.valueOf,0);
+        
+        // Create the milliseconds array from the execution tree
+        $scope.executionTree.forEach(function(node) {
+            if (node.data.time !== undefined) {
+
+                // Ignore artefacts (durations > 100ms)
+                var time = Math.min(node.data.time, 100) || 1;
+
+                for (var i=node.data.timestamp, max=node.data.timestamp + time ; i<max ; i++) {
+                    millisecondsArray[i] |= 1;
+                }
+            }
+        });
+
+        // Pre-fill array of 200 elements
+        $scope.timeline = Array.apply(null, new Array(numberOfIntervals + 1)).map(Number.prototype.valueOf,0);
+
+        // Create the timeline from the milliseconds array
+        millisecondsArray.forEach(function(value, timestamp) {
+            if (value === 1) {
+                $scope.timeline[Math.floor(timestamp / $scope.timelineIntervalDuration)] += 1;
+            }
+        });
+        
+        // Get the maximum value of the array (needed for display)
+        $scope.timelineMax = Math.max.apply(Math, $scope.timeline);
+    }
+
+
+    function initProfiler() {
+        $scope.profilerData = $scope.executionTree;
+    }
+
+    $scope.changeScript = function() {
+        initExecutionTree();
+        initTimeline();
+        initProfiler();
+    };
+
+    $scope.findLineIndexByTimestamp = function(timestamp) {
+        var lineIndex = 0;
+
+        for (var i = 0; i < $scope.executionTree.length; i ++) {
+            var delta = $scope.executionTree[i].data.timestamp - timestamp;
+            
+            if (delta < $scope.timelineIntervalDuration) {
+                lineIndex = i;
+            }
+
+            if (delta > 0) {
+                break;
+            }
+        }
+
+        return lineIndex;
+    };
+
+
+    $scope.backToDashboard = function() {
+        $location.path('/result/' + $scope.runId);
+    };
+
+    $scope.testAgain = function() {
+        API.relaunchTest($scope.result);
+    };
+
+    loadResults();
+
+}]);
+
+timelineCtrl.directive('scrollOnClick', ['$animate', '$timeout', function($animate, $timeout) {
+    return {
+        restrict: 'A',
+        link: function (scope, element, attributes) {            
+            // When the user clicks on the timeline, find the right profiler line and scroll to it
+            element.on('click', function() {
+                var lineIndex = scope.findLineIndexByTimestamp(attributes.scrollOnClick);
+                var lineElement = angular.element(document.getElementById('line_' + lineIndex));
+                
+                // Animate the background color to "flash" the row
+                lineElement.addClass('highlight');
+                $timeout(function() {
+                    $animate.removeClass(lineElement, 'highlight');
+                    scope.$digest();
+                }, 50);
+
+
+                window.scrollTo(0, lineElement[0].offsetTop);
+            });
+        }
+    };
+}]);

+ 702 - 0
test/www/unminified-stylesheet.css

@@ -0,0 +1,702 @@
+/* Timeline colors, related to Window Performances */
+.execution {
+  text-align: center;
+}
+.selectScript {
+  padding-bottom: 2em;
+  font-size: 0.9em;
+}
+.selectScript select {
+  max-width: 30em;
+}
+.selectScript.empty {
+  font-size: 0.8em;
+}
+.selectScript.empty select {
+  width: 10em;
+}
+.timeline {
+  margin: 2em 0 5em;
+}
+.timeline .chart {
+  position: relative;
+  width: 100%;
+  border-bottom: 1px solid #000;
+}
+.timeline .startTime,
+.timeline .endTime {
+  position: absolute;
+  bottom: 0.5em;
+  font-size: 0.8em;
+}
+.timeline .startTime {
+  left: 0em;
+}
+.timeline .endTime {
+  right: 0em;
+}
+.timeline .chartPoints {
+  display: table;
+  height: 100px;
+  width: 99%;
+  margin: 0 auto;
+}
+.timeline .interval {
+  display: table-cell;
+  position: relative;
+  height: 100px;
+  width: 0.5%;
+}
+.timeline .interval .color {
+  position: absolute;
+  bottom: 0;
+  width: 100%;
+}
+.timeline .interval .color.clickable {
+  cursor: pointer;
+}
+.timeline div.interval:hover {
+  background: #9C4274;
+}
+.timeline .interval:hover .color {
+  background: #F04DA7;
+}
+.timeline .domComplete.interval {
+  background: #ede3ff;
+}
+.timeline .domComplete .color {
+  background: #c2a3ff;
+}
+.timeline .domContentLoadedEnd.interval {
+  background: #d8f0f0;
+}
+.timeline .domContentLoadedEnd .color {
+  background: #7ecccc;
+}
+.timeline .domContentLoaded.interval {
+  background: #e0ffd1;
+}
+.timeline .domContentLoaded .color {
+  background: #a7e846;
+}
+.timeline .domInteractive.interval {
+  background: #fffccc;
+}
+.timeline .domInteractive .color {
+  background: #ffe433;
+}
+.timeline .domCreation.interval {
+  background: #ffe0cc;
+}
+.timeline .domCreation .color {
+  background: #ff6600;
+}
+.timeline .tooltip.detailsOverlay {
+  position: absolute;
+  display: none;
+  width: auto;
+  padding: 0.5em 1em;
+  top: -1.5em;
+  right: 1em;
+}
+.timeline .interval:hover .tooltip {
+  display: block;
+}
+.timeline .legend {
+  display: table;
+  width: 100%;
+  margin-top: 1em;
+}
+.timeline .legend > div {
+  display: table-row;
+}
+.timeline .legend > div > div {
+  position: relative;
+  display: table-cell;
+  margin-top: 1em;
+}
+.timeline .titles {
+  font-weight: bold;
+}
+.timeline .titles > div {
+  padding: 0 1em 0 2em;
+}
+.timeline .tips {
+  font-size: 0.7em;
+}
+.timeline .tips > div {
+  padding: 1em 1em 0 0;
+}
+.timeline .legend .color {
+  display: block;
+  position: absolute;
+  left: 0;
+  height: 1.5em;
+  width: 1.5em;
+  border-radius: 0.2em;
+}
+.filters {
+  margin: 1em auto;
+  padding: 0.5em;
+  min-width: 30em;
+  width: 30%;
+  border: 1px dotted #aaa;
+  text-align: left;
+}
+.subFilters {
+  margin-left: 3em;
+  font-size: 0.9em;
+}
+.table {
+  display: table;
+  width: 100%;
+  border-spacing: 0.25em;
+}
+.table > div {
+  display: table-row;
+}
+.table > .headers > div {
+  font-weight: bold;
+  padding: 0.5em 1em;
+}
+.table > div > div {
+  padding: 0.1em 1em;
+  background: #f2f2f2;
+  display: table-cell;
+  text-align: left;
+}
+.table > div.jsError > .type,
+.table > div.jsError > .value {
+  color: #e74c3c;
+  font-weight: bold;
+}
+.table > div.windowPerformance > div,
+.table > div.windowPerformance > div.startTime {
+  background: #EBD8E2;
+}
+.table > div.showingDetails > div {
+  background: #f1c40f;
+}
+.table > div.highlight > div.startTime {
+  background-color: #C0F090;
+}
+.table > div.highlight-remove {
+  transition: 3s;
+}
+.table > div.highlight-remove > div.startTime {
+  transition: background-color 3s ease-in;
+}
+.table > div > .index {
+  color: #bbb;
+  word-break: normal;
+}
+.table > div > .type {
+  white-space: nowrap;
+}
+.table .children {
+  margin-top: 0.2em;
+  font-size: 0.8em;
+  line-height: 1.6em;
+}
+.table .child {
+  margin-left: 0.5em;
+}
+.table .child > .child {
+  margin-left: 1em;
+}
+.table .child:before {
+  content: "↳";
+}
+.table .child .childArgs {
+  display: none;
+}
+.table .child span {
+  position: relative;
+}
+.table .child span:hover {
+  background: #EBD8E2;
+}
+.table .child span:hover div {
+  display: inline-block;
+}
+.table .child span:hover .childArgs {
+  display: block;
+  position: absolute;
+  padding: 0 1em 0 2em;
+  left: 100%;
+  top: 0;
+  background: #EBD8E2;
+  line-height: 1.3em;
+  height: 1.3em;
+  z-index: 2;
+}
+.table .showingDetails .child span:hover {
+  background: inherit;
+}
+.table .showingDetails .child span:hover .childArgs {
+  display: none;
+}
+.table > div > .value {
+  width: 70%;
+  word-break: break-all;
+}
+.table > div > .details {
+  position: relative;
+}
+.table .details .icon-question {
+  color: #f1c40f;
+  cursor: pointer;
+}
+.table .icon-warning {
+  display: inline-block;
+  width: 0.8em;
+}
+.detailsOverlay {
+  display: none;
+  position: absolute;
+  right: 3em;
+  top: -3em;
+  width: 45em;
+  min-height: 1em;
+  padding: 0 1em 1em;
+  background: #fff;
+  border: 2px solid #f1c40f;
+  border-radius: 0.5em;
+  z-index: 2;
+}
+@media screen and (max-width: 1024px) {
+  .detailsOverlay {
+    width: 25em;
+  }
+}
+.showDetails .detailsOverlay {
+  display: block;
+}
+.detailsOverlay .closeBtn {
+  position: absolute;
+  top: 0.5em;
+  right: 0.5em;
+  color: #f1c40f;
+  cursor: pointer;
+}
+.detailsOverlay .advice {
+  color: #e74c3c;
+  font-weight: bold;
+}
+.detailsOverlay .trace {
+  word-break: break-all;
+}
+.table > div > .duration,
+.table > div > .startTime {
+  text-align: center;
+  white-space: nowrap;
+}
+.table > div > .startTime.domComplete {
+  background: #ede3ff;
+}
+.table > div > .startTime.domContentLoadedEnd {
+  background: #d8f0f0;
+}
+.table > div > .startTime.domContentLoaded {
+  background: #e0ffd1;
+}
+.table > div > .startTime.domInteractive {
+  background: #fffccc;
+}
+.table > div > .startTime.domCreation {
+  background: #ffe0cc;
+}
+.execution .icon-warning {
+  color: #e74c3c;
+  cursor: pointer;
+}
+.queryWithoutResultsFilterOn > div {
+  display: none;
+}
+.queryWithoutResultsFilterOn > div.queryWithoutResults {
+  display: table-row;
+}
+.jQueryCallOnEmptyObjectFilterOn > div {
+  display: none;
+}
+.jQueryCallOnEmptyObjectFilterOn > div.jQueryCallOnEmptyObject {
+  display: table-row;
+}
+.eventNotDelegatedFilterOn > div {
+  display: none;
+}
+.eventNotDelegatedFilterOn > div.eventNotDelegated {
+  display: table-row;
+}
+.jsErrorFilterOn > div {
+  display: none;
+}
+.jsErrorFilterOn > div.jsError {
+  display: table-row;
+}
+.testedUrl {
+  color: inherit;
+}
+.summary {
+  text-align: center;
+}
+.summary .globalScore {
+  display: table;
+  width: 60%;
+  margin: 3em auto;
+}
+.summary .globalScore > div {
+  display: table-cell;
+  width: 50%;
+  vertical-align: middle;
+}
+.summary .globalScore .globalGrade {
+  margin: 0.5 auto;
+  width: 2.5em;
+  height: 2.5em;
+  line-height: 2.5em;
+  border-radius: 0.5em;
+  font-size: 3em;
+  font-weight: bold;
+  vertical-align: middle;
+}
+.summary .globalScore .on100 {
+  font-size: 1.2em;
+  font-weight: bold;
+  margin: 0.5em 0 1em;
+}
+.summary .globalScore .screenshotWrapper:hover {
+  opacity: 0.75;
+}
+.summary .globalScore .screenshotWrapper:hover:after {
+  position: absolute;
+  width: 1.25em;
+  height: 1.25em;
+  top: 0.7em;
+  left: 1.55em;
+  font-size: 3em;
+  color: #FFF;
+  background: #000;
+  border-radius: 0.2em;
+  text-align: center;
+  content: "+";
+  opacity: 0.85;
+}
+.summary .globalScore .screenshotWrapper.phone:hover:after {
+  top: 1.7em;
+  left: 0.64em;
+}
+.summary .globalScore .screenshotWrapper.tablet:hover:after {
+  top: 1.5em;
+  left: 0.9em;
+}
+.summary .notations {
+  display: table;
+  width: 80%;
+  margin: 0 10% 1.5em;
+  border-spacing: 1em;
+}
+.summary .notations > div {
+  display: table-row;
+}
+.summary .notations > div > div {
+  display: table-cell;
+  height: 2.5em;
+  vertical-align: middle;
+}
+.summary .notations .category {
+  font-weight: bold;
+  text-align: center;
+  width: 20%;
+}
+.summary .notations .criteria {
+  font-weight: normal;
+  width: 75%;
+}
+.summary .notations .A.categoryScore,
+.summary .notations .B.categoryScore,
+.summary .notations .C.categoryScore,
+.summary .notations .D.categoryScore,
+.summary .notations .E.categoryScore,
+.summary .notations .F.categoryScore,
+.summary .notations .NA.categoryScore {
+  width: 2.5em;
+  max-width: 2.5em;
+  min-width: 2.5em;
+  font-size: 2em;
+  text-align: center;
+  border-radius: 0.5em;
+  font-weight: bold;
+}
+.summary .notations .grade .A,
+.summary .notations .grade .B,
+.summary .notations .grade .C,
+.summary .notations .grade .D,
+.summary .notations .grade .E,
+.summary .notations .grade .F,
+.summary .notations .grade .NA {
+  width: 1em;
+  height: 1em;
+  font-size: 1em;
+  color: transparent;
+  margin: 0 auto;
+  border-radius: 0.5em;
+}
+.summary .notations .criteria .table {
+  width: 100%;
+}
+.summary .notations .criteria .table > div:hover > div {
+  background: #EBD8E2;
+  cursor: pointer;
+}
+.summary .notations .criteria .table > div:hover > div.info {
+  background: #FFF;
+}
+.summary .notations .criteria .table > div:hover > div.info .icon-question {
+  color: #EBD8E2;
+}
+.summary .notations .criteria .grade {
+  width: 10%;
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+  vertical-align: middle;
+}
+.summary .notations .criteria .label {
+  width: 70%;
+}
+.summary .notations .criteria .result {
+  width: 18%;
+  font-weight: bold;
+  white-space: nowrap;
+  text-align: center;
+  vertical-align: middle;
+}
+.summary .notations .warning .label,
+.summary .notations .warning .result,
+.summary .notations .icon-warning {
+  color: #FF1919;
+}
+.summary .notations .criteria .info {
+  width: 2%;
+  text-align: center;
+  vertical-align: middle;
+  background: #FFF;
+  padding-left: 0.1em;
+  padding-right: 0.1em;
+}
+.summary .notations .criteria .icon-question {
+  color: transparent;
+}
+.summary .fromShare {
+  margin-bottom: 3em;
+}
+.summary .apiTip {
+  font-size: 0.8em;
+  margin-bottom: 4em;
+  color: #413;
+}
+.summary .apiTip a {
+  color: inherit;
+}
+.summary .tweet .tweetText {
+  color: #413;
+  background: #F2F2F2;
+  border: none;
+  width: 25em;
+  padding: 0.4em;
+  border-radius: 0.5em;
+  box-shadow: 0.05em 0.1em 0 0 #999;
+}
+.summary .tweet .tweetButton,
+.summary .tweet .linkedinButton {
+  color: #413;
+  background: #F2F2F2;
+  margin-right: 0;
+}
+.summary .tweet .tweetButton:hover,
+.summary .tweet .linkedinButton:hover {
+  color: #F2F2F2;
+  background: #e74c3c;
+}
+.summary .tweet input {
+  font-size: 0.9em;
+}
+
+
+.promess {
+  padding: 0em 2em;
+  margin-bottom: 0.5em;
+  font-weight: normal;
+  font-size: 1.2em;
+}
+.price {
+  padding: 0em 2em 3em;
+  margin-top: 0em;
+  font-size: 0.9em;
+}
+.url {
+  width: 50%;
+}
+.launchBtn {
+  background: #ffa319;
+  color: #fff;
+}
+.launchBtn:focus {
+  background: #e74c3c;
+}
+.launchBtn.disabled {
+  background: #deaca6;
+}
+.launchBtn.disabled:focus {
+  color: #ddd;
+}
+.settings {
+  width: 50%;
+  margin: 0 auto;
+}
+.settings input,
+.settings select {
+  font-size: 1em;
+}
+.settings input[type=text] {
+  width: 100%;
+  min-width: 4em;
+}
+.device {
+  margin-top: 3em;
+}
+.device .item {
+  display: inline-block;
+  margin: 1em 0.75em;
+  width: 5.5em;
+  height: 5.5em;
+  color: #FFF;
+  border: 1px solid #FFF;
+  padding: 1px;
+  border-radius: 0.5em;
+  cursor: pointer;
+  text-decoration: none;
+  font-size: 0.8em;
+}
+                                                                                       
+.device .item.active {                             
+  color: #ffa319;                             
+  border: 2px solid #ffa319;                             
+  padding: 0;                             
+}                             
+.device .item:hover {                             
+  color: #ffa319;                             
+}                             
+.device .item > div {                             
+  margin: 0.2em 0 0.1em;                             
+  font-size: 3em;                             
+}                             
+.settingsTooltip {
+  position: relative;
+}
+.settingsTooltip span {
+  font-size: 0.8em;
+  vertical-align: text-top;
+}
+.settingsTooltip div {
+  display: none;
+  position: absolute;
+  padding: 0.5em;
+  width: 25em;
+  background: #FFF;
+  color: #000;
+  font-size: 0.8em;
+  border-radius: 1em;
+  border: 2px solid #ffa319;
+  white-space: normal;
+  z-index: 2;
+}
+.settingsTooltip:hover div {
+  display: block;
+}
+.showAdvanced {
+  display: inline-block;
+  margin-top: 2em;
+  color: #FFF;
+  text-decoration: none;
+  font-size: 0.9em;
+}
+.showAdvanced:hover {
+  color: #ffa319;
+}
+.currentSettings {
+  font-size: 0.9em;
+}
+.currentSettings span {
+  color: #ffa319;
+}
+.currentSettings span:after {
+  color: #FFF;
+  content: ",";
+}
+.currentSettings span:last-child:after {
+  content: "";
+}
+.advanced {
+  margin: 1em 0 0;
+  display: table;
+  width: 100%;
+  text-align: left;
+  border-spacing: 0.75em;
+}
+.advanced > div {
+  display: table-row;
+}
+.advanced > div > div {
+  display: table-cell;
+  width: 75%;
+}
+.advanced > div > div.label {
+  width: 25%;
+  white-space: nowrap;
+}
+.advanced .subTable {
+  display: table;
+  border-spacing: 0;
+  width: 100%;
+}
+.advanced .subTable > div {
+  display: table-row;
+}
+.advanced .subTable > div > div {
+  display: table-cell;
+  padding: 0 0 0.75em;
+}
+.features {
+  display: table;
+  width: 50%;
+  margin: 6em auto 0;
+  font-size: 0.9em;
+  color: #413;
+}
+.features > div {
+  width: 33.3%;
+  display: table-cell;
+  padding: 0 1.5em;
+}
+input[type=submit],
+input.url {
+  padding: 0 0.5em;
+  margin: 0.5em;
+  font-size: 1.2em;
+  height: 2em;
+  border: 0 solid;
+  border-radius: 0.5em;
+  box-shadow: 0.1em 0.2em 0 0 #5e2846;
+  outline: none;
+}
+input[type=submit]:hover {
+  color: #ddd;
+}
+input[type=submit].clicked {
+  color: #ddd;
+  position: relative;
+  left: 0.1em;
+  top: 0.2em;
+  box-shadow: none;
+}