diff --git a/README.md b/README.md index 149dce3..485fe7b 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,6 @@ You can use [ngrok](https://ngrok.com/), a tool that creates a secure tunnel bet If your project is not accessible from outside, or if you want to fork and improve the tool, you can build your own instance. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/Install-your-private-server). -## Help needed! -There are so many things left to do, **your help would be greatly appreciated**! Please report bugs, ask for evolutions and come code with me. - - -## License -Please read the [license](LICENSE). Icons are the property of [IcoMoon.io](https://icomoon.io/) - - ## Author Gaël Métais. I'm a webperf freelance. Follow me on Twitter [@gaelmetais](https://twitter.com/gaelmetais), I tweet about Web Performances, Front-end and new versions of YellowLabTools! diff --git a/front/src/css/rule.css b/front/src/css/rule.css index bef4497..686d735 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -179,7 +179,7 @@ .checker { /* Checkerboard background */ background-color: #ddd; - background-image: linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa), linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa); + background-image: linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA), linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA); background-size: 1em 1em; background-position: 0 0, 0.5em 0.5em; } diff --git a/front/src/css/timeline.css b/front/src/css/timeline.css index 5b2c872..11920fb 100644 --- a/front/src/css/timeline.css +++ b/front/src/css/timeline.css @@ -62,34 +62,34 @@ background: #F04DA7; } .timeline .domComplete.interval { - background: #ede3ff; + background: #EDE3FF; } .timeline .domComplete .color { - background: #c2a3ff; + background: #C2A3FF; } .timeline .domContentLoadedEnd.interval { - background: #d8f0f0; + background: #D8F0F0; } .timeline .domContentLoadedEnd .color { - background: #7ecccc; + background: #7ECCCC; } .timeline .domContentLoaded.interval { - background: #e0ffd1; + background: #E0FFD1; } .timeline .domContentLoaded .color { - background: #a7e846; + background: #A7E846; } .timeline .domInteractive.interval { - background: #fffccc; + background: #FFFCCC; } .timeline .domInteractive .color { - background: #ffe433; + background: #FFE433; } .timeline .domCreation.interval { - background: #ffe0cc; + background: #FFE0CC; } .timeline .domCreation .color { - background: #ff6600; + background: #FF6600; } .timeline .tooltip.detailsOverlay { position: absolute; @@ -294,19 +294,19 @@ white-space: nowrap; } .table > div > .startTime.domComplete { - background: #ede3ff; + background: #EDE3FF; } .table > div > .startTime.domContentLoadedEnd { - background: #d8f0f0; + background: #D8F0F0; } .table > div > .startTime.domContentLoaded { - background: #e0ffd1; + background: #E0FFD1; } .table > div > .startTime.domInteractive { - background: #fffccc; + background: #FFFCCC; } .table > div > .startTime.domCreation { - background: #ffe0cc; + background: #FFE0CC; } .execution .icon-warning { color: #e74c3c; diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 398c17f..1cdaaa4 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/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() { diff --git a/front/src/views/rule.html b/front/src/views/rule.html index d888850..22733a5 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -100,6 +100,11 @@
This is the number of different colors defined in CSS.
Your CSS project will be easier to maintain if you keep a small color set.
", + "message": "This is the number of different colors defined in CSS.
Your CSS will be easier to maintain if you keep a small color set.
", "isOkThreshold": 30, "isBadThreshold": 150, "isAbnormalThreshold": 400, @@ -522,6 +522,43 @@ var policies = { }; } }, + "cssBreakpoints": { + "tool": "mediaQueriesChecker", + "label": "Breakpoints count", + "message": "This is the number of different breakpoints found in the stylesheets' media queries.
Please note this rule is based on min-width, max-width, min-device-width and max-device-width media queries only.
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.
", + "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": "This is the number of CSS rules inside media queries that address small screens.
The common good practice, when creating a responsive website, is to write it \"mobile-first\". More explanation in this great article.
", + "isOkThreshold": 25, + "isBadThreshold": 200, + "isAbnormalThreshold": 1000, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders + }; + } + }, "cssImports": { "tool": "phantomas", "label": "Uses of @import", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 30230ec..9c2f68f 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/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": { diff --git a/lib/runner.js b/lib/runner.js index f35879d..e5fa3c7 100644 --- a/lib/runner.js +++ b/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); diff --git a/lib/tools/colorDiff.js b/lib/tools/colorDiff.js index bf9cdae..d7217d6 100644 --- a/lib/tools/colorDiff.js +++ b/lib/tools/colorDiff.js @@ -46,7 +46,9 @@ var colorDiff = function() { var deduplicatedColors = {}; parsedOffenders.forEach(function(color) { - deduplicatedColors[color] = color; + if (color !== null) { + deduplicatedColors[color] = color; + } }); return Object.keys(deduplicatedColors).map(this.parseColor); @@ -54,7 +56,7 @@ var colorDiff = function() { this.parseOffender = function(offender) { var regexResult = /^(.*) \(\d+ times\)$/.exec(offender); - return regexResult[1]; + return regexResult ? regexResult[1] : null; }; this.parseColor = function(color) { diff --git a/lib/tools/mediaQueriesChecker.js b/lib/tools/mediaQueriesChecker.js new file mode 100644 index 0000000..d66bdf0 --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/package.json b/package.json index 771aca9..4e1b5c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yellowlabtools", - "version": "1.8.2", + "version": "1.9.0", "description": "Online tool to audit a webpage for performance and front-end quality issues", "license": "GPL-2.0", "author": { @@ -20,50 +20,51 @@ }, "main": "./lib/index.js", "dependencies": { - "angular": "1.4.7", - "angular-animate": "1.4.7", - "angular-chart.js": "0.8.5", + "angular": "1.4.8", + "angular-animate": "1.4.8", + "angular-chart.js": "0.8.6", "angular-local-storage": "0.2.2", - "angular-resource": "1.4.7", - "angular-route": "1.4.7", - "angular-sanitize": "1.4.7", + "angular-resource": "1.4.8", + "angular-route": "1.4.8", + "angular-sanitize": "1.4.8", "async": "1.5.0", "body-parser": "1.14.1", "chart.js": "1.0.2", - "clean-css": "3.4.6", + "clean-css": "3.4.8", "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", - "imagemin-jpegoptim": "4.0.0", + "imagemin": "4.0.0", + "imagemin-jpegoptim": "4.1.0", "jstoxml": "0.2.3", "lwip": "0.0.8", - "meow": "3.4.2", + "meow": "3.6.0", "minimize": "1.7.4", "parse-color": "1.0.0", "phantomas": "1.13.0", "ps-node": "0.0.5", "q": "1.4.1", - "request": "2.65.0", - "rimraf": "2.4.3", + "request": "2.67.0", + "rimraf": "2.4.4", "temporary": "0.0.8", "try-thread-sleep": "1.0.0", - "uglify-js": "2.5.0" + "uglify-js": "2.6.1" }, "devDependencies": { "chai": "~3.4.0", "grunt": "~0.4.5", - "grunt-blanket": "~0.0.8", - "grunt-contrib-clean": "~0.6.0", + "grunt-blanket": "~0.0.10", + "grunt-contrib-clean": "~0.7.0", "grunt-contrib-concat": "~0.5.1", "grunt-contrib-copy": "~0.8.2", "grunt-contrib-cssmin": "~0.14.0", "grunt-contrib-htmlmin": "~0.6.0", "grunt-contrib-jshint": "~0.11.3", - "grunt-contrib-less": "~1.0.1", - "grunt-contrib-uglify": "~0.10.0", + "grunt-contrib-less": "~1.1.0", + "grunt-contrib-uglify": "~0.11.0", "grunt-env": "~0.4.4", "grunt-express": "~1.4.1", "grunt-filerev": "~2.3.1", @@ -72,7 +73,7 @@ "grunt-mocha-test": "~0.12.7", "grunt-replace": "~0.11.0", "grunt-usemin": "~3.1.1", - "grunt-webfont": "~0.5.4", + "grunt-webfont": "~1.0.2", "matchdep": "~1.0.0", "mocha": "~2.3.2", "sinon": "~1.17.2", diff --git a/test/core/mediaQueriesCheckerTest.js b/test/core/mediaQueriesCheckerTest.js new file mode 100644 index 0000000..7b3222b --- /dev/null +++ b/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)