Przeglądaj źródła

Two new media queries related rules

Gaël Métais 9 lat temu
rodzic
commit
394d3a5b25

+ 48 - 0
front/src/js/controllers/ruleCtrl.js

@@ -48,6 +48,54 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
                 tooltipTemplate: '<%=label%>: <%=value%> KB'
             };
         }
+
+        // Init "Breakpoints" chart
+        if ($scope.policyName === 'cssBreakpoints' && $scope.rule.value > 0) {
+
+            // Seek for the biggest breakpoint
+            var max = 0;
+            $scope.rule.offendersObj.forEach(function(offender) {
+                if (offender.pixels > max) {
+                    max = offender.pixels;
+                }
+            });
+            max = Math.max(max + 100, 1400);
+
+            // We group offenders 10px by 10px
+            var GROUP_SIZE = 20;
+
+            // Generate an empty array of values
+            $scope.breakpointsLabels = [];
+            $scope.breakpointsData = [[]];
+            for (var i = 0; i <= max / GROUP_SIZE; i++) {
+                $scope.breakpointsLabels[i] = '';
+                $scope.breakpointsData[0][i] = 0;
+            }
+
+            // Fill it with results
+            $scope.rule.offendersObj.forEach(function(offender) {
+                var group = Math.floor((offender.pixels + 1) / GROUP_SIZE);
+
+                if ($scope.breakpointsLabels[group] !== '') {
+                    $scope.breakpointsLabels[group] += '/';
+                }
+                $scope.breakpointsLabels[group] += offender.breakpoint;
+
+                $scope.breakpointsData[0][group] += offender.count;
+            });
+
+            $scope.breakpointsColours = ['#9c4274'];
+            $scope.breakpointsOptions = {
+                scaleShowGridLines: false,
+                barShowStroke: false,
+                showTooltips: false,
+                pointDot: false,
+                responsive: true,
+                maintainAspectRatio: true,
+                strokeColor: 'rgba(20, 200, 20, 1)',
+                scaleFontSize: 9
+            };
+        }
     }
 
     $scope.backToDashboard = function() {

+ 21 - 0
front/src/views/rule.html

@@ -100,6 +100,11 @@
                         <div class="similarColors checker"><div ng-style="{'background-color': offender.color1, 'color': offender.isDark ? '#FFF' : '#000'}">{{offender.color1}}</div><div ng-style="{'background-color': offender.color2, 'color': offender.isDark ? '#FFF' : '#000'}">{{offender.color2}}</div></div>
                     </div>
 
+                    <div ng-if="policyName === 'cssMobileFirst'">
+                        <b>{{offender.query}}</b> for <ng-pluralize count="offender.rules" when="{'one':'1 rule','other':'{} rules'}"></ng-pluralize>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
                     <div ng-if="policyName === 'cssParsingErrors'">
                         <b>{{offender.error}}</b>
                         <file-and-line file="offender.file" line="offender.line" column="offender.column"></file-and-line>
@@ -348,6 +353,22 @@
         Please open the <a href="/result/{{runId}}/timeline#filter=eventNotDelegated">JS timeline, filtered by "Events not delegated"</a>
     </div>
 
+    <div ng-if="policyName === 'cssBreakpoints'">
+        <div ng-if="rule.value > 0" class="cssBreakpointsGraph">
+            <h3>Breakpoints distribution graph</h3>
+            <canvas class="chart chart-line" chart-data="breakpointsData" chart-labels="breakpointsLabels" chart-options="breakpointsOptions" chart-legend="true" chart-colours="breakpointsColours" width="600" height="250"></canvas>
+            <h3>Breakpoints list</h3>
+            <div class="offendersTable">
+                <div ng-repeat="offender in rule.offendersObj | orderBy:'pixels'">
+                    <div>Breakpoint <b>{{offender.breakpoint}}</b> involves <ng-pluralize count="offender.count" when="{'one': '1 rule', 'other': '{} rules'}"></ng-pluralize></div>
+                </div>
+            </div>
+        </div>
+        <div ng-if="rule.value === 0">
+        No breakpoint
+        </div>
+    </div>
+
     <div ng-if="!rule && rule !== null" class="notFound">
         <h2>404</h2>
         Rule "{{policyName}}"" not found

+ 38 - 1
lib/metadata/policies.js

@@ -464,7 +464,7 @@ var policies = {
     "cssColors": {
         "tool": "phantomas",
         "label": "Colors count",
-        "message": "<p>This is the number of different colors defined in CSS.</p><p>Your CSS project will be easier to maintain if you keep a small color set.</p>",
+        "message": "<p>This is the number of different colors defined in CSS.</p><p>Your CSS will be easier to maintain if you keep a small color set.</p>",
         "isOkThreshold": 30,
         "isBadThreshold": 150,
         "isAbnormalThreshold": 400,
@@ -522,6 +522,43 @@ var policies = {
             };
         }
     },
+    "cssBreakpoints": {
+        "tool": "mediaQueriesChecker",
+        "label": "Breakpoints count",
+        "message": "<p>This is the number of different breakpoints found in the stylesheets' media queries.</p><p>Please note this rule is based on <i>min-width</i>, <i>max-width</i>, <i>min-device-width</i> and <i>max-device-width</i> media queries only.</p><p>Your CSS will be easier to maintain if you keep a reasonable number of breakpoints. Try to make a fluid design - using percents - to avoid the creation of numerous breakpoints.</p>",
+        "isOkThreshold": 6,
+        "isBadThreshold": 40,
+        "isAbnormalThreshold": 60,
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            var offendersTable = [];
+
+            for (var offender in offenders) {
+                offendersTable.push({
+                    breakpoint: offender,
+                    count: offenders[offender].count,
+                    pixels: offenders[offender].pixels
+                });
+            }
+
+            return offendersTable;
+        }
+    },
+    "cssMobileFirst": {
+        "tool": "mediaQueriesChecker",
+        "label": "Not mobile-first media queries",
+        "message": "<p>This is the number of media queries that address small screens.</p><p>The common good practice, when creating a responsive website, is to write it \"mobile-first\". More explanation in <a href=\"http://www.sitepoint.com/introduction-mobile-first-media-queries\" target=\"_blank\">this great article</a>.</p>",
+        "isOkThreshold": 25,
+        "isBadThreshold": 200,
+        "isAbnormalThreshold": 600,
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return {
+                count: offenders.length,
+                list: offenders
+            };
+        }
+    },
     "cssImports": {
         "tool": "phantomas",
         "label": "Uses of @import",

+ 3 - 1
lib/metadata/scoreProfileGeneric.json

@@ -71,7 +71,9 @@
                 "cssComplexSelectors": 2,
                 "cssComplexSelectorsByAttribute": 1.5,
                 "cssColors": 0.5,
-                "similarColors": 0.5
+                "similarColors": 0.5,
+                "cssBreakpoints": 1,
+                "cssMobileFirst": 1
             }
         },
         "badCSS": {

+ 4 - 0
lib/runner.js

@@ -4,6 +4,7 @@ var debug                   = require('debug')('ylt:runner');
 var phantomasWrapper        = require('./tools/phantomas/phantomasWrapper');
 var jsExecutionTransformer  = require('./tools/jsExecutionTransformer');
 var colorDiff               = require('./tools/colorDiff');
+var mediaQueriesChecker     = require('./tools/mediaQueriesChecker');
 var weightChecker           = require('./tools/weightChecker/weightChecker');
 var rulesChecker            = require('./rulesChecker');
 var scoreCalculator         = require('./scoreCalculator');
@@ -32,6 +33,9 @@ var Runner = function(params) {
         // Compare colors
         data = colorDiff.compareAllColors(data);
 
+        // Check media queries
+        data = mediaQueriesChecker.analyzeMediaQueries(data);
+
         // Redownload every file
         return weightChecker.recheckAllFiles(data);
 

+ 168 - 0
lib/tools/mediaQueriesChecker.js

@@ -0,0 +1,168 @@
+var debug   = require('debug')('ylt:mediaQueriesChecker');
+var parseMediaQuery = require('css-mq-parser');
+var offendersHelpers = require('../offendersHelpers');
+
+
+var mediaQueriesChecker = function() {
+    'use strict';
+
+    var MOBILE_MIN_BREAKPOINT = 200;
+    var MOBILE_MAX_BREAKPOINT = 300;
+
+    this.analyzeMediaQueries = function(data) {
+        debug('Starting to check all media queries...');
+
+        var offenders = data.toolsResults.phantomas.offenders.cssMediaQueries;
+        var mediaQueries = (offenders) ? this.parseAllMediaQueries(offenders) : [];
+        
+        var notMobileFirstCount = 0;
+        var notMobileFirstOffenders = [];
+
+        var breakpointsOffenders = {};
+
+        for (var i = 0; i < mediaQueries.length; i++) {
+            var item = mediaQueries[i];
+
+            if (!item) {
+                continue;
+            }
+
+            if (item.isForMobile) {
+                notMobileFirstCount += item.mediaQuery.rules;
+                notMobileFirstOffenders.push(item.mediaQuery);
+            }
+
+            for (var j = 0; j < item.breakpoints.length; j++) {
+                var breakpointString = item.breakpoints[j].string;
+                if (!breakpointsOffenders[breakpointString]) {
+                    breakpointsOffenders[breakpointString] = {
+                        count: 1,
+                        pixels: item.breakpoints[j].pixels
+                    };
+                } else {
+                    breakpointsOffenders[breakpointString].count += 1;
+                }
+            }
+        }
+
+        data.toolsResults.mediaQueriesChecker = {
+            metrics: {
+                cssMobileFirst: notMobileFirstCount,
+                cssBreakpoints: Object.keys(breakpointsOffenders).length
+            },
+            offenders: {
+                cssMobileFirst: notMobileFirstOffenders,
+                cssBreakpoints: breakpointsOffenders
+            }
+        };
+
+        debug('End of media queries check');
+
+        return data;
+    };
+
+    this.parseAllMediaQueries = function(offenders) {
+        return offenders.map(this.parseOneMediaQuery);
+    };
+
+    this.parseOneMediaQuery = function(offender) {
+        var splittedOffender = offendersHelpers.cssOffenderPattern(offender);
+        var parts = /^@media (.*) \((\d+ rules)\)$/.exec(splittedOffender.css);
+
+        if (!parts) {
+            debug('Failed to parse media query ' + offender);
+            return false;
+        }
+
+        var rulesCount = parseInt(parts[2], 10);
+        var query = parts[1];
+
+        var isForMobile = false;
+        var breakpoints = [];
+
+        try {
+
+            var ast = parseMediaQuery(query);
+
+            var min = 0;
+            var max = Infinity;
+            var pixels;
+
+            ast.forEach(function(astItem) {
+                astItem.expressions.forEach(function(expression) {
+                    if (expression.feature === 'width' || expression.feature === 'device-width') {
+                        if (astItem.inverse === false) {
+                            if (expression.modifier === 'max') {
+                                pixels = toPixels(expression.value);
+                                max = Math.min(max, pixels);
+                                breakpoints.push({
+                                    string: expression.value,
+                                    pixels: pixels
+                                });
+                            } else if (expression.modifier === 'min') {
+                                pixels = toPixels(expression.value);
+                                min = Math.max(min, pixels);
+                                breakpoints.push({
+                                    string: expression.value,
+                                    pixels: pixels
+                                });
+                            }
+                        } else if (astItem.inverse === true) {
+                            if (expression.modifier === 'max') {
+                                pixels = toPixels(expression.value);
+                                min = Math.max(min, pixels);
+                                breakpoints.push({
+                                    string: expression.value,
+                                    pixels: pixels
+                                });
+                            } else if (expression.modifier === 'min') {
+                                pixels = toPixels(expression.value);
+                                max = Math.min(max, pixels);
+                                breakpoints.push({
+                                    string: expression.value,
+                                    pixels: pixels
+                                });
+                            }
+                        }
+                    }
+                });
+            });
+
+            isForMobile = (min <= MOBILE_MIN_BREAKPOINT && max >= MOBILE_MAX_BREAKPOINT && max !== Infinity);
+
+        } catch(error) {
+            debug('Failed to parse media query ' + offender);
+        }
+
+        return {
+            mediaQuery: {
+                query: query,
+                rules: rulesCount,
+                file: splittedOffender.file,
+                line: splittedOffender.line,
+                column: splittedOffender.column
+            },
+            isForMobile: isForMobile,
+            breakpoints: breakpoints
+        };
+    };
+
+    // Parses a size in em, pt (or px) and returns it in px
+    function toPixels(size) {
+        var splittedSize = /^([\d\.]+)(.*)/.exec(size);
+        var value = parseFloat(splittedSize[1]);
+        var unit = splittedSize[2];
+
+        if (unit === 'em') {
+            return value * 16;
+        }
+
+        if (unit === 'pt') {
+            return value / 12 * 16;
+        }
+
+        return value;
+    }
+};
+
+module.exports = new mediaQueriesChecker();

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "color-diff": "0.1.7",
     "compression": "1.6.0",
     "cors": "2.7.1",
+    "css-mq-parser": "0.0.3",
     "debug": "2.2.0",
     "express": "4.13.3",
     "imagemin": "3.2.2",

+ 55 - 0
test/core/mediaQueriesCheckerTest.js

@@ -0,0 +1,55 @@
+var should = require('chai').should();
+var mediaQueriesChecker = require('../../lib/tools/mediaQueriesChecker');
+
+describe('mediaQueriesChecker', function() {
+    
+    it('should parse mediaQueryes correctly', function() {
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 1024px) (1 rules) <http://domain.com/css/stylesheet.css> @ 269:1').should.deep.equal({
+            mediaQuery: {
+                query: 'screen and (max-width: 1024px)',
+                rules: 1,
+                file: 'http://domain.com/css/stylesheet.css',
+                line: 269,
+                column: 1
+            },
+            isForMobile: true,
+            breakpoints: [{string: '1024px', pixels: 1024}]
+        });
+    });
+
+    it('should determine if it\'s for mobile correctly', function() {
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 1024px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media (max-width:1024px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 320px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 290px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (min-width: 320px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (min-width: 600px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 20em) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (min-width: 40em) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media (min-width: 600px) and (max-width: 1000px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media (min-width: 180px) and (max-width: 400px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media (min-width: 180px) and (max-width: 290px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 600px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 180px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 1000px) and (max-width: 600px) (1 rules) <file> @ 1:1').isForMobile.should.equal(false);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 400px) and (max-width: 180px) (1 rules) <file> @ 1:1').isForMobile.should.equal(true);
+    });
+
+    it('should count breakpoints correctly', function() {
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 1024px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '1024px', pixels: 1024}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media (max-width:1024px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '1024px', pixels: 1024}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 320px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '320px', pixels: 320}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media (min-width: 600px) and (max-width: 1000px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '600px', pixels: 600}, {string: '1000px', pixels: 1000}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 180px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '180px', pixels: 180}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media not all and (min-width: 1000px) and (max-width: 600px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '1000px', pixels: 1000}, {string: '600px', pixels: 600}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media (max-height:500px) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([]);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 100em) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '100em', pixels: 1600}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 120pt) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '120pt', pixels: 160}]);
+        mediaQueriesChecker.parseOneMediaQuery('@media screen and (max-width: 40.2em) (1 rules) <file> @ 1:1').breakpoints.should.deep.equal([{string: '40.2em', pixels: 643.2}]);
+    });
+
+    it('should fail silently', function() {
+        mediaQueriesChecker.parseOneMediaQuery('@media bad syntax (1 rules) <file> @ 1:1').isForMobile.should.equal(false); 
+    });
+
+});