├── .babelrc ├── .eslintrc ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── gulp ├── .eslintrc ├── clean.js ├── default.js ├── docs.js ├── lint.js ├── module.js ├── pages.js ├── styles.js ├── test.js ├── test │ └── phantomjs.js └── watch.js ├── gulpfile.babel.js ├── jest.config.js ├── package-lock.json ├── package.json ├── pages ├── index.html ├── linter.html └── store.html ├── src ├── bookmarklet.js ├── collector.js ├── collector │ ├── checkLazyImages.js │ ├── find.js │ ├── index.js │ ├── readData.js │ ├── readDimensions.js │ ├── readImages.js │ ├── readMarkup.js │ └── readMediaQueries.js ├── linter.js ├── linter │ ├── descriptors │ │ ├── duplicate.js │ │ ├── duplicate.md │ │ ├── index.js │ │ ├── malformed.js │ │ ├── malformed.md │ │ ├── mixed.js │ │ ├── mixed.md │ │ ├── wMissingSizes.js │ │ ├── wMissingSizes.md │ │ ├── wrongSize.js │ │ ├── wrongSize.md │ │ ├── wrongX.js │ │ ├── wrongX.md │ │ ├── xAndSizes.js │ │ └── xAndSizes.md │ ├── fallbacks │ │ ├── index.js │ │ ├── src.js │ │ └── src.md │ ├── images │ │ ├── differentContent.js │ │ ├── differentContent.md │ │ ├── differentRatio.js │ │ ├── differentRatio.md │ │ ├── index.js │ │ ├── missingFittingSrc.js │ │ ├── missingFittingSrc.md │ │ ├── sameContent.js │ │ ├── sameContent.md │ │ ├── sizesAuto.js │ │ ├── sizesAuto.md │ │ ├── sizesAutoLazy.js │ │ ├── sizesAutoLazy.md │ │ ├── wrongSizes.js │ │ └── wrongSizes.md │ ├── index.js │ ├── markup │ │ ├── duplicateImg.js │ │ ├── duplicateImg.md │ │ ├── extra.js │ │ ├── extra.md │ │ ├── index.js │ │ ├── missingImg.js │ │ ├── missingImg.md │ │ ├── sourceSrc.js │ │ ├── sourceSrc.md │ │ ├── wrongOrder.js │ │ └── wrongOrder.md │ └── prepareMediaQueries.js ├── reporter.js ├── store.js ├── test.js └── util │ ├── allMarkupSources.js │ ├── allSources.js │ ├── computeLength.js │ ├── computeSizesAttribute.js │ ├── computeSrcsetWidths.js │ ├── error.js │ ├── getDocs.js │ ├── hashDistance.js │ ├── jpegQuality.js │ ├── mediaMatchesViewport.js │ ├── mediaToStringArray.js │ ├── parseMedia.js │ ├── roundWidth.js │ ├── sameRatio.js │ ├── setStyles.js │ ├── splitCommaSeparatedListOfComponentValues.js │ └── stripViewportQueries.js ├── styles ├── _common.sass ├── _normalize.sass └── styles.sass └── tests └── util ├── computeLength.js ├── computeSrcsetWidths.js └── roundWidth.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "ignore": [ 4 | "gulpfile.js", 5 | "dist/**/*.js" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true 5 | }, 6 | "globals": { 7 | "__filename": false, 8 | "__dirname": false 9 | }, 10 | "rules": { 11 | "quotes": [2, "single"], 12 | "comma-dangle": [2, "always-multiline"], 13 | "strict": [2, "never"], 14 | "no-console": 1, 15 | "no-use-before-define": [2, "nofunc"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ausi] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /tmp/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016 Martin Auswöger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RespImageLint - Linter for Responsive Images 2 | 3 | RespImageLint is a bookmarklet that checks images of a webpage against a list of common mistakes and best practises. 4 | 5 | ## Usage 6 | 7 | Go to [ausi.github.io/respimagelint](https://ausi.github.io/respimagelint/), add the bookmarklet to your browser toolbar and run it on your website. 8 | 9 | If the bookmarklet doesn’t work for you 10 | because of security restrictions of your browser 11 | there is a Chrome extension available 12 | created by [Peter Neumann](https://github.com/peter-neumann-dev): \ 13 | 14 | 15 | ## Browser Support 16 | 17 | * Firefox 45+ 18 | * Chrome 40+ 19 | * Edge 12+ 20 | * Yandex 14+ 21 | 22 | ## Contribute 23 | 24 | * Create a [new issue on GitHub](https://github.com/ausi/respimagelint/issues/new) if you have a question, a suggestion or found a bug. 25 | 26 | ## Sponsors 27 | 28 | Thanks to all sponsors that help to bring this project forward. You can [become a sponsor now](https://github.com/sponsors/ausi) too. 29 | 30 | * [TinyBit](https://tinybit.com/) sponsored features 31 | [#62](https://github.com/ausi/respimagelint/issues/62), 32 | [#63](https://github.com/ausi/respimagelint/issues/63), 33 | [#68](https://github.com/ausi/respimagelint/issues/68), 34 | [#67](https://github.com/ausi/respimagelint/issues/67), 35 | the [unit test suite](https://github.com/ausi/respimagelint/compare/e94115e3e2edae21a76583a41bc0bbee76aeaf2e...9d1c6d8ceb9eca396c50abf21d47aa5420e16bce) 36 | and more 37 | * [My sponsors on GitHub](https://github.com/sponsors/ausi) 38 | 39 | ## License 40 | 41 | MIT 42 | -------------------------------------------------------------------------------- /gulp/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "node": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /gulp/clean.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import del from 'del'; 3 | 4 | gulp.task('clean', () => 5 | del([ 6 | 'dist/*.*', 7 | 'tmp', 8 | ]) 9 | ); 10 | -------------------------------------------------------------------------------- /gulp/default.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import lint from './lint'; 3 | import docs from './docs'; 4 | import styles from './styles'; 5 | import pages from './pages'; 6 | import module from './module'; 7 | 8 | gulp.task('default', gulp.series( 9 | 'lint', 10 | 'docs', 11 | gulp.parallel( 12 | 'styles', 13 | 'pages', 14 | 'module:collector', 15 | 'module:linter', 16 | 'module:store' 17 | ) 18 | )); 19 | -------------------------------------------------------------------------------- /gulp/docs.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import glob from 'glob'; 5 | import { marked } from 'marked'; 6 | import prism from 'prismjs'; 7 | 8 | let markdownConfig = { 9 | gfm: true, 10 | tables: true, 11 | breaks: true, 12 | highlight: (code, lang) => { 13 | if (lang && prism.languages[lang]) { 14 | return prism.highlight(code, prism.languages[lang], lang); 15 | } 16 | }, 17 | }; 18 | 19 | gulp.task('docs', callback => { 20 | glob(path.join(__dirname, '../src/linter/**/*.md'), (err, files) => { 21 | 22 | if (err) { 23 | return; 24 | } 25 | 26 | let rootDir = path.parse(__dirname).dir; 27 | const docs = {}; 28 | 29 | files.forEach(file => { 30 | let key = file.substr(rootDir.length + 1, file.length - 4 - rootDir.length).split(path.sep); 31 | key.shift(); 32 | key.shift(); 33 | key = key.join('.'); 34 | docs[key] = fs.readFileSync(file, 'utf-8'); 35 | }); 36 | 37 | const data = JSON.stringify(docs); 38 | 39 | let tmpDir = path.join(rootDir, 'tmp'); 40 | if (!fs.existsSync(tmpDir)) { 41 | fs.mkdirSync(tmpDir); 42 | } 43 | 44 | fs.writeFileSync(path.join(tmpDir, 'docs.json'), data); 45 | 46 | var docsHtml = ''; 47 | var indexHtml = ''; 48 | 49 | files.forEach(file => { 50 | let key = file.substr(rootDir.length + 1, file.length - 4 - rootDir.length).split(path.sep); 51 | key.shift(); 52 | key.shift(); 53 | key = key.join('.'); 54 | var title = marked(getDocs(key, 'title'), markdownConfig).replace(/<\/?p>/gi, '').trim(); 55 | indexHtml += '
  • ' + title + '
  • '; 56 | docsHtml += '
    ' 57 | docsHtml += '

    ' + title + '

    \n'; 58 | docsHtml += marked(getDocs(key, 'text'), markdownConfig); 59 | docsHtml += '

    Correct

    \n'; 60 | docsHtml += marked(getDocs(key, 'Good'), markdownConfig); 61 | docsHtml += '

    Incorrect

    \n'; 62 | docsHtml += marked(getDocs(key, 'Bad'), markdownConfig); 63 | docsHtml += '
    ' 64 | }); 65 | 66 | var docsHtml = '\n' 67 | + '\n' 68 | + 'RespImageLint\n' 69 | + '\n' 70 | + '\n' 71 | + '
    \n' 72 | + '

    RespImageLint Linters

    \n' 73 | + '\n' 76 | + docsHtml; 77 | 78 | fs.writeFileSync(path.join(__dirname, '..', 'dist', 'docs.html'), docsHtml); 79 | 80 | callback(); 81 | 82 | function getDocs(key, section) { 83 | 84 | let doc = docs[key]; 85 | 86 | if (!section) { 87 | return doc; 88 | } 89 | 90 | if (section === 'title') { 91 | return doc.split('\n')[0].substr(2); 92 | } 93 | 94 | if (section === 'text') { 95 | doc = doc.split(/^#[^\n]*/); 96 | } 97 | else { 98 | doc = doc.split('\n\n## ' + section + '\n\n'); 99 | } 100 | 101 | if (doc.length < 2) { 102 | return ''; 103 | } 104 | 105 | return doc[1].split('\n\n##')[0].trim(); 106 | 107 | } 108 | 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /gulp/lint.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import eslint from 'gulp-eslint'; 3 | 4 | gulp.task('lint', () => 5 | gulp.src(['./src/**/*.js']) 6 | .pipe(eslint()) 7 | .pipe(eslint.format()) 8 | ); 9 | -------------------------------------------------------------------------------- /gulp/module.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import babelify from 'babelify'; 3 | import browserify from 'browserify'; 4 | import source from 'vinyl-source-stream'; 5 | import livereload from 'gulp-livereload'; 6 | 7 | gulp.task('module:collector', () => 8 | browserify({ 9 | entries: './src/collector.js', 10 | //debug: true, 11 | }) 12 | .transform(babelify) 13 | .transform('brfs') 14 | .transform({global: true}, 'uglifyify') 15 | .bundle() 16 | .pipe(source('collector.js')) 17 | .pipe(gulp.dest('./dist')) 18 | .pipe(livereload()) 19 | ); 20 | 21 | gulp.task('module:linter', () => 22 | browserify({ 23 | entries: './src/linter.js', 24 | //debug: true, 25 | }) 26 | .transform(babelify) 27 | .transform('brfs') 28 | .transform({global: true}, 'uglifyify') 29 | .bundle() 30 | .pipe(source('linter.js')) 31 | .pipe(gulp.dest('./dist')) 32 | .pipe(livereload()) 33 | ); 34 | 35 | gulp.task('module:store', () => 36 | browserify({ 37 | entries: './src/store.js', 38 | //debug: true, 39 | }) 40 | .transform(babelify) 41 | .transform('brfs') 42 | .transform({global: true}, 'uglifyify') 43 | .bundle() 44 | .pipe(source('store.js')) 45 | .pipe(gulp.dest('./dist')) 46 | .pipe(livereload()) 47 | ); 48 | 49 | gulp.task('module:test', () => 50 | browserify({ 51 | entries: './src/test.js', 52 | debug: true, 53 | }) 54 | .transform(babelify) 55 | .transform('brfs') 56 | .bundle() 57 | .pipe(source('script.js')) 58 | .pipe(gulp.dest('./tmp/test')) 59 | ); 60 | -------------------------------------------------------------------------------- /gulp/pages.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import eslint from 'gulp-eslint'; 5 | import livereload from 'gulp-livereload'; 6 | import replace from 'gulp-replace'; 7 | import uglify from 'uglify-js'; 8 | 9 | gulp.task('pages', () => 10 | gulp.src(['./pages/**/*.html']) 11 | .pipe(replace( 12 | '{{> bookmarklet}}', 13 | 'javascript:(function(){' + encodeURIComponent(uglify.minify( 14 | path.join(__dirname, '..', 'src', 'bookmarklet.js') 15 | ).code) + '})()' 16 | )) 17 | .pipe(gulp.dest('./dist')) 18 | .pipe(livereload()) 19 | ); 20 | -------------------------------------------------------------------------------- /gulp/styles.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import sassDart from 'sass'; 3 | import sassGulp from 'gulp-sass'; 4 | import postcss from 'gulp-postcss'; 5 | import autoprefixer from 'autoprefixer'; 6 | import livereload from 'gulp-livereload'; 7 | 8 | const sass = sassGulp(sassDart); 9 | 10 | gulp.task('styles', () => 11 | gulp.src('./styles/*.sass') 12 | .pipe(sass.sync().on('error', sass.logError)) 13 | .pipe(postcss([ 14 | autoprefixer(), 15 | ])) 16 | .pipe(gulp.dest('./dist')) 17 | .pipe(livereload()) 18 | ); 19 | -------------------------------------------------------------------------------- /gulp/test.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import gutil from 'gulp-util'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import childProcess from 'child_process'; 6 | import phantomjs from 'phantomjs-prebuilt'; 7 | //import lwip from '@randy.tarampi/lwip'; 8 | import crypto from 'crypto'; 9 | 10 | gulp.task('test', gulp.series('docs', 'module:test', callback => { 11 | 12 | createHtmlFile(htmlFile => { 13 | let args = [ 14 | path.join(__dirname, 'test', 'phantomjs.js'), 15 | JSON.stringify({ 16 | url: 'file://' + htmlFile, 17 | }), 18 | ]; 19 | childProcess.execFile(phantomjs.path, args, {maxBuffer: Infinity}, (err, stdout, stderr) => { 20 | if (err || stderr || !stdout) { 21 | throw new Error([err && err.message, stderr, stdout].join('\n')); 22 | } 23 | let data = JSON.parse(stdout); 24 | if (!data || !data.length) { 25 | throw new Error('Bad output from PhantomJS'); 26 | } 27 | callback(reviewTestResult(data)); 28 | }); 29 | }); 30 | 31 | })); 32 | 33 | function reviewTestResult(data) { 34 | 35 | let passed = {}; 36 | let failed = {}; 37 | 38 | data.forEach(image => { 39 | 40 | let errors = []; 41 | if (image.data.errors) { 42 | errors = errors.concat(image.data.errors); 43 | } 44 | if (image.data.img && image.data.img.errors) { 45 | errors = errors.concat(image.data.img.errors); 46 | } 47 | image.data.sources.forEach(source => { 48 | if (source.errors) { 49 | errors = errors.concat(source.errors); 50 | } 51 | }); 52 | let filteredErrors = errors.filter(error => { 53 | return error.key === image.test.key; 54 | }); 55 | 56 | let key = image.test.key + '.' + image.test.type; 57 | 58 | if (image.test.type === 'good') { 59 | if (errors.length) { 60 | failed[key] = failed[key] || []; 61 | failed[key].push(image.data); 62 | } 63 | else { 64 | passed[key] = (passed[key] || 0) + 1; 65 | } 66 | } 67 | else { 68 | if (!filteredErrors.length) { 69 | failed[key] = failed[key] || []; 70 | failed[key].push(image.data); 71 | } 72 | else { 73 | passed[key] = (passed[key] || 0) + 1; 74 | } 75 | } 76 | 77 | }); 78 | 79 | Object.keys(passed).forEach(key => { 80 | gutil.log('Test', gutil.colors.cyan(key), gutil.colors.green(passed[key], 'passed')); 81 | }); 82 | 83 | Object.keys(failed).forEach(key => { 84 | gutil.log('Test', gutil.colors.cyan(key), gutil.colors.red(failed[key].length, 'failed')); 85 | }); 86 | 87 | if (Object.keys(failed).length) { 88 | gutil.log(JSON.stringify(failed, undefined, 2)); 89 | return new gutil.PluginError('test', { 90 | message: 'Tests failed.', 91 | }); 92 | } 93 | 94 | } 95 | 96 | function createHtmlFile(callback) { 97 | 98 | let tmpDir = path.join(__dirname, '..', 'tmp', 'test'); 99 | if (!fs.existsSync(tmpDir)) { 100 | fs.mkdir(tmpDir); 101 | } 102 | 103 | let docs = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'tmp', 'docs.json'))); 104 | let html = ''; 105 | html += ''; 106 | 107 | html += Object.keys(docs).map(key => { 108 | let sections = docs[key].split('\n## '); 109 | sections.shift(); 110 | return sections.map(section => { 111 | let testType; 112 | if (section.substr(0, 4) === 'Good') { 113 | testType = 'good'; 114 | } 115 | else if (section.substr(0, 3) === 'Bad') { 116 | testType = 'bad'; 117 | } 118 | else { 119 | return ''; 120 | } 121 | let code = section.split('\n```html\n'); 122 | code.shift(); 123 | code = code.map(block => 124 | '
    \n' 125 | + block.split('\n```\n')[0] 126 | + '\n
    \n' 127 | ); 128 | return code.join(''); 129 | }).join(''); 130 | }).join(''); 131 | 132 | fs.writeFileSync(path.join(tmpDir, 'index.html'), html); 133 | 134 | createImageFiles(tmpDir, html, () => { 135 | callback(path.join(tmpDir, 'index.html')); 136 | }); 137 | 138 | } 139 | 140 | function createImageFiles(tmpDir, html, callback) { 141 | 142 | let images = []; 143 | 144 | regExpMatchAll(/src(?:set)?="([^"]+)"/g, html).forEach(match => { 145 | let urls = match[1].split(/[\s,]+/); 146 | urls.forEach(url => { 147 | if (url.match(/.+\.jpg$/)) { 148 | images.push(url); 149 | } 150 | }); 151 | }); 152 | 153 | images = images.filter((url, index) => images.indexOf(url) === index); 154 | 155 | let imagesCreated = 0; 156 | 157 | images.forEach(url => { 158 | createImage(url, tmpDir, () => { 159 | imagesCreated++; 160 | if (imagesCreated === images.length) { 161 | callback(); 162 | } 163 | }); 164 | }); 165 | 166 | } 167 | 168 | function createImage(url, tmpDir, callback) { 169 | 170 | url = url.split('?')[0]; 171 | 172 | let format = url.split('.')[1]; 173 | let name = 'default'; 174 | let size = url.split('.')[0]; 175 | if (size.indexOf('-') !== -1) { 176 | name = size.split('-')[0]; 177 | size = size.split('-')[1]; 178 | } 179 | size = size.split('x').map(val => parseInt(val)); 180 | 181 | let color = '777777'; 182 | if (name !== 'default') { 183 | color = crypto.createHash('md5').update(name).digest('hex').substr(0, 6); 184 | } 185 | 186 | /* 187 | lwip.create(size[0], size[1], [ 188 | parseInt(color.substr(0, 2), 16), 189 | parseInt(color.substr(2, 2), 16), 190 | parseInt(color.substr(4, 2), 16), 191 | ], (err, image) => { 192 | if (err) { 193 | throw err; 194 | } 195 | image.writeFile(path.join(tmpDir, url), format, err2 => { 196 | if (err2) { 197 | throw err2; 198 | } 199 | callback(); 200 | }); 201 | }); 202 | */ 203 | 204 | callback(); 205 | } 206 | 207 | function regExpMatchAll(regexp, str) { 208 | if (!regexp.global) { 209 | throw new Error('Regular expression is missing the global flag.'); 210 | } 211 | let matches = []; 212 | let match; 213 | while ((match = regexp.exec(str)) !== null) { 214 | matches.push(match); 215 | } 216 | return matches; 217 | } 218 | -------------------------------------------------------------------------------- /gulp/test/phantomjs.js: -------------------------------------------------------------------------------- 1 | /*global phantom: false*/ 2 | 3 | var page = require('webpage').create(); 4 | var system = require('system'); 5 | 6 | if (system.args.length !== 2) { 7 | console.error('Missing argument'); 8 | phantom.exit(1); 9 | } 10 | 11 | var config = JSON.parse(system.args[1]); 12 | 13 | page.onCallback = function(data) { 14 | console.log(JSON.stringify(data)); 15 | phantom.exit(); 16 | }; 17 | 18 | // @TODO this isn’t working currently, so errors in script.js get ignored 19 | page.onError = function(msg) { 20 | console.log('ERROR: ' + msg); 21 | phantom.exit(1); 22 | }; 23 | 24 | page.open(config.url, function(status) { 25 | 26 | if (status !== 'success') { 27 | console.error(status); 28 | phantom.exit(1); 29 | return; 30 | } 31 | 32 | page.includeJs('script.js'); 33 | 34 | setTimeout(function() { 35 | console.error('20sec timeout reached before the tests complete.'); 36 | phantom.exit(1); 37 | }, 20000); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /gulp/watch.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import livereload from 'gulp-livereload'; 3 | 4 | gulp.task('watch', gulp.series('default', function watching () { 5 | livereload.listen(); 6 | gulp.watch('./src/**/*.*', gulp.series('default')); 7 | gulp.watch('./pages/**/*.*', gulp.series('pages')); 8 | gulp.watch('./styles/**/*.*', gulp.series('styles')); 9 | })); 10 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | /*global require*/ 2 | import requireDir from 'require-dir'; 3 | requireDir('gulp'); 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testMatch: [ 4 | '/tests/**/*.[jt]s?(x)', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RespImageLint", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "devDependencies": { 7 | "autoprefixer": "^6.1.2", 8 | "babel-eslint": "^6.0.4", 9 | "babel-jest": "^23.6.0", 10 | "babel-polyfill": "^6.2.0", 11 | "babel-preset-env": "^1.7.0", 12 | "babelify": "^7.2.0", 13 | "brfs": "^1.4.0", 14 | "browserify": "^13.0.1", 15 | "css-mq-parser": "0.0.3", 16 | "del": "^2.1.0", 17 | "eslint": "^4.18.2", 18 | "glob": "^7.0.3", 19 | "gulp": "^4.0.0", 20 | "gulp-babel": "~6.1.1", 21 | "gulp-eslint": "^2.0.0", 22 | "gulp-livereload": "^4.0.2", 23 | "gulp-postcss": "^6.0.1", 24 | "gulp-replace": "^0.5.4", 25 | "gulp-sass": "^5.1.0", 26 | "gulp-util": "^3.0.6", 27 | "jest": "^27.4.7", 28 | "lodash": "^4.17.19", 29 | "marked": "^4.0.10", 30 | "phantomjs-prebuilt": "^2.1.7", 31 | "prismjs": "^1.25.0", 32 | "require-dir": "^0.3.0", 33 | "run-sequence": "^1.1.0", 34 | "sass": "^1.48.0", 35 | "uglify-js": "^2.6.1", 36 | "uglifyify": "^5.0.2", 37 | "vinyl-source-stream": "^2.0.0", 38 | "whatwg-fetch": "^1.0.0" 39 | }, 40 | "scripts": { 41 | "test": "jest && gulp test" 42 | }, 43 | "author": "", 44 | "license": "ISC" 45 | } 46 | -------------------------------------------------------------------------------- /pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | RespImageLint 4 | 9 | 10 | 11 | 12 |
    13 | 14 |

    RespImageLint - Linter for Responsive Images

    15 | 16 |

    RespImageLint is a bookmarklet that checks images of a webpage against a list of common mistakes and best practises. Just run the bookmarklet and see if it detects any problems with your images. You can find more information on the Project page on GitHub.

    17 | 18 |
    19 | Lint Images 25 |

    Drag this link to your bookmarks or right click and bookmark it.

    26 | 31 |
    32 | 33 |
    34 | -------------------------------------------------------------------------------- /pages/linter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | RespImageLint 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /pages/store.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/bookmarklet.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var script = document.createElement('script'); 4 | script.id = 'respimagelint-script'; 5 | script.type = 'text/javascript'; 6 | script.src = '{{baseUrl}}collector.js?' + Date.now(); 7 | 8 | document.body.appendChild(script); 9 | 10 | })(); 11 | -------------------------------------------------------------------------------- /src/collector.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | import collector from './collector/index'; 3 | import setStyles from './util/setStyles'; 4 | 5 | const script = document.getElementById('respimagelint-script'); 6 | const scriptBase = script ? script.src.split('?')[0].replace(/[^/]+$/, '') : 'https://ausi.github.io/respimagelint/'; 7 | let iframe; 8 | 9 | collector(document).then(data => { 10 | 11 | data.href = document.location.href; 12 | 13 | return new Promise(resolve => { 14 | 15 | window.addEventListener('message', function(event) { 16 | if (event.data === 'respImageLintStoreReady') { 17 | event.source.postMessage(JSON.stringify(data), '*'); 18 | } 19 | if (event.data === 'respImageLintStoreDone') { 20 | resolve(); 21 | } 22 | }); 23 | 24 | setStyles(document.body, {overflow: 'hidden'}); 25 | setStyles(document.documentElement, {overflow: 'hidden'}); 26 | 27 | iframe = document.createElement('iframe'); 28 | setStyles(iframe, { 29 | position: 'fixed', 30 | top: '5vh', 31 | left: '5vw', 32 | 'z-index': 2147483647, 33 | width: '90vw', 34 | 'max-width': 'none', 35 | 'min-width': 0, 36 | height: '90vh', 37 | 'max-height': 'none', 38 | 'min-height': 0, 39 | border: 0, 40 | 'border-radius': '10px', 41 | background: '#fff', 42 | 'box-shadow': '0 25px 50px rgba(0, 0, 0, 0.6)', 43 | 'overscroll-behavior': 'contain', 44 | }); 45 | 46 | iframe.src = scriptBase + 'store.html'; 47 | document.body.appendChild(iframe); 48 | }); 49 | 50 | }).then(() => { 51 | iframe.src = scriptBase + 'linter.html'; 52 | }).catch(err => { 53 | alert(err); 54 | document.location.reload(); 55 | }); 56 | -------------------------------------------------------------------------------- /src/collector/checkLazyImages.js: -------------------------------------------------------------------------------- 1 | import setStyles from '../util/setStyles'; 2 | 3 | const lazyAttributes = [ 4 | 'data-src', 5 | 'data-srcset', 6 | 'data-sizes', 7 | 'data-original', 8 | 'data-original-set', 9 | 'data-pagespeed-lazy-src', 10 | ]; 11 | 12 | export default function checkLazyImages(iframe) { 13 | 14 | return new Promise((resolve) => { 15 | 16 | const lazyElements = iframe.contentWindow.document.querySelectorAll([ 17 | ...lazyAttributes.map(attr => 'img[' + attr +']'), 18 | ...lazyAttributes.map(attr => 'picture > source[' + attr +']'), 19 | ].join(',')); 20 | 21 | if (!lazyElements.length) { 22 | resolve(); 23 | return; 24 | } 25 | 26 | setStyles(iframe, { 27 | height: iframe.contentWindow.document.body.scrollHeight + 'px', 28 | }); 29 | 30 | setTimeout(resolve, 5000); 31 | 32 | }); 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/collector/find.js: -------------------------------------------------------------------------------- 1 | export default function find(document) { 2 | 3 | let images = []; 4 | 5 | Array.from(document.querySelectorAll('img')).forEach(img => { 6 | 7 | let image = { 8 | dom: { 9 | img, 10 | sources: [], 11 | }, 12 | }; 13 | 14 | for (let node = img.parentNode; node; node = node.parentNode) { 15 | if (node.tagName === 'PICTURE') { 16 | image.dom.picture = node; 17 | break; 18 | } 19 | } 20 | 21 | if (image.dom.picture) { 22 | image.dom.sources = Array.from( 23 | image.dom.picture.querySelectorAll('source') 24 | ); 25 | } 26 | 27 | images.push(image); 28 | 29 | }); 30 | 31 | Array.from(document.querySelectorAll('picture')).forEach(picture => { 32 | // Add picture elements with missing img tag 33 | if (!picture.querySelector('img')) { 34 | images.push({ 35 | dom: { 36 | picture, 37 | sources: Array.from( 38 | picture.querySelectorAll('source') 39 | ), 40 | }, 41 | }); 42 | } 43 | }); 44 | 45 | return images; 46 | } 47 | -------------------------------------------------------------------------------- /src/collector/index.js: -------------------------------------------------------------------------------- 1 | if (!global._babelPolyfill) { 2 | require('babel-polyfill'); 3 | } 4 | 5 | import find from './find'; 6 | import checkLazyImages from './checkLazyImages'; 7 | import readMediaQueries from './readMediaQueries'; 8 | import readData from './readData'; 9 | import readMarkup from './readMarkup'; 10 | import readDimensions from './readDimensions'; 11 | import readImages from './readImages'; 12 | import setStyles from '../util/setStyles'; 13 | 14 | export default function (document, includeDom = false) { 15 | 16 | let iframe, progressBar, progressMessage, overlay; 17 | let data = {}; 18 | 19 | function progress(done, message) { 20 | progressBar.value = done; 21 | progressMessage.textContent = message; 22 | setStyles(overlay, {opacity: done * 0.5 + 0.5}); 23 | document.title = Math.round(done * 100) + '% ' + message; 24 | } 25 | 26 | return Promise.resolve().then(() => { 27 | 28 | setStyles(document.body, {overflow: 'hidden'}); 29 | setStyles(document.documentElement, {overflow: 'hidden'}); 30 | 31 | iframe = document.createElement('iframe'); 32 | iframe.src = document.location.href.split('#')[0] + (document.location.search ? '&' : '?') + document.location.hash; 33 | setStyles(iframe, { 34 | position: 'absolute', 35 | top: 0, 36 | left: 0, 37 | opacity: 0, 38 | 'z-index': 2147483647, 39 | width: '100vw', 40 | 'max-width': 'none', 41 | 'min-width': 0, 42 | height: '100vh', 43 | 'max-height': 'none', 44 | 'min-height': 0, 45 | border: 0, 46 | }); 47 | 48 | let promise = new Promise((resolve, reject) => { 49 | function checkLoaded() { 50 | if ( 51 | iframe.contentWindow.jQuery 52 | && iframe.contentWindow.jQuery.active !== 0 53 | ) { 54 | setTimeout(checkLoaded, 10); 55 | } 56 | else { 57 | resolve(); 58 | } 59 | } 60 | iframe.addEventListener('load', () => { 61 | try { 62 | let doc = iframe.contentWindow.document; 63 | } 64 | catch(e) { 65 | reject(new Error('Failed loading page into iframe.')); 66 | return; 67 | } 68 | setTimeout(checkLoaded); 69 | }); 70 | }); 71 | 72 | document.body.appendChild(iframe); 73 | 74 | overlay = document.createElement('div'); 75 | document.body.appendChild(overlay); 76 | setStyles(overlay, { 77 | position: 'fixed', 78 | top: 0, 79 | left: 0, 80 | right: 0, 81 | bottom: 0, 82 | 'background-color': 'rgba(255, 255, 255, 0)', 83 | opacity: 0.5, 84 | 'z-index': 2147483647, 85 | transition: 'background-color 1s linear', 86 | }); 87 | overlay.offsetWidth; // force layout 88 | setStyles(overlay, {'background-color': '#fff'}); 89 | 90 | progressBar = document.createElement('progress'); 91 | setStyles(progressBar, { 92 | position: 'fixed', 93 | top: '50%', 94 | left: '50%', 95 | transform: 'translate(-50%, -50%)', 96 | width: '33%', 97 | 'z-index': 2147483647, 98 | }); 99 | document.body.appendChild(progressBar); 100 | 101 | progressMessage = document.createElement('div'); 102 | setStyles(progressMessage, { 103 | position: 'fixed', 104 | top: '50%', 105 | left: '0', 106 | transform: 'translate(0, 50px)', 107 | width: '100%', 108 | 'text-align': 'center', 109 | 'font-size': '16px', 110 | color: 'black', 111 | 'white-space': 'pre-line', 112 | 'text-shadow': '0 0 2px white, 0 0 2px white, 0 0 2px white, 0 0 2px white', 113 | 'z-index': 2147483647, 114 | }); 115 | document.body.appendChild(progressMessage); 116 | 117 | progress(0.05, 'Loading page into frame...'); 118 | 119 | return promise; 120 | 121 | }).then(() => { 122 | 123 | progress(0.075, 'Check for lazy loading images'); 124 | 125 | return checkLazyImages(iframe); 126 | 127 | }).then(() => { 128 | 129 | progress(0.09, 'Read media queries'); 130 | 131 | return readMediaQueries(iframe.contentWindow.document, data); 132 | 133 | }).then(() => { 134 | 135 | progress(0.1, 'Resizing'); 136 | 137 | data.data = find(iframe.contentWindow.document) 138 | .map(readData) 139 | .map(readMarkup); 140 | 141 | return readDimensions(iframe, data.data, (progressDone, viewport) => { 142 | progress(0.1 + (0.8 * progressDone), 'Resizing to ' + viewport); 143 | }); 144 | 145 | }).then(() => { 146 | 147 | progress(0.9, 'Reading image'); 148 | 149 | return readImages(iframe.contentWindow.document, data.data, (progressDone, count, image) => { 150 | if (image) { 151 | progress(0.9 + (0.1 * progressDone), 'Reading image ' + Math.round(progressDone * count) + ' of ' + count + '\n' + image.url); 152 | } 153 | }); 154 | 155 | }).then(() => { 156 | 157 | progress(1, 'Done'); 158 | 159 | if (!includeDom) { 160 | data.data.forEach(image => { 161 | delete image.dom; 162 | }); 163 | } 164 | 165 | document.body.removeChild(iframe); 166 | document.body.removeChild(progressBar); 167 | document.body.removeChild(progressMessage); 168 | document.body.removeChild(overlay); 169 | setStyles(document.body, {overflow: ''}); 170 | setStyles(document.documentElement, {overflow: ''}); 171 | 172 | return data; 173 | 174 | }); 175 | 176 | } 177 | -------------------------------------------------------------------------------- /src/collector/readData.js: -------------------------------------------------------------------------------- 1 | import parseMedia from '../util/parseMedia'; 2 | import splitCommaSeparatedListOfComponentValues from '../util/splitCommaSeparatedListOfComponentValues'; 3 | 4 | export default function readData(image) { 5 | 6 | let img = image.dom.img; 7 | 8 | image.data = { 9 | img: img && { 10 | src: img.getAttribute('src'), 11 | srcset: parseSrcset(img.getAttribute('srcset')), 12 | sizes: parseSizes(img.getAttribute('sizes')), 13 | loading: img.getAttribute('loading') || undefined, 14 | }, 15 | sources: image.dom.sources.map(source => { 16 | return { 17 | srcset: parseSrcset(source.getAttribute('srcset')), 18 | sizes: parseSizes(source.getAttribute('sizes')), 19 | media: parseMedia(source.getAttribute('media')), 20 | type: source.getAttribute('type') || undefined, 21 | }; 22 | }), 23 | }; 24 | 25 | return image; 26 | 27 | } 28 | function parseSrcset(attribute) { 29 | if (!attribute) { 30 | return []; 31 | } 32 | const srcset = []; 33 | attribute.replace( 34 | /,*(\S*?[^\s,])(?:\s,|,+\s|,?$|\s([^,]+)(?:,|$))/g, 35 | (match, src, descriptor) => { 36 | srcset.push({ 37 | src, 38 | descriptor: descriptor && descriptor.trim(), 39 | }); 40 | } 41 | ); 42 | return srcset; 43 | } 44 | 45 | function parseSizes(attribute) { 46 | if (!attribute) { 47 | return []; 48 | } 49 | return splitCommaSeparatedListOfComponentValues(attribute).map(size => { 50 | let media; 51 | size = size.trim().replace(/^(?:not\s+)?\(.+?\)(?:\s*(?:and|or)\s*\(.+?\))*\s+/, match => { 52 | media = parseMedia(match.trim()); 53 | return ''; 54 | }); 55 | return {size, media}; 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/collector/readDimensions.js: -------------------------------------------------------------------------------- 1 | import setStyles from '../util/setStyles'; 2 | 3 | const minWidth = 300; 4 | const maxWidth = 3000; 5 | const stepSize = 20; 6 | const aspectRatios = [16 / 9, 3 / 4]; 7 | 8 | export default function readDimensions(iframe, data, progress) { 9 | 10 | return new Promise((resolve) => { 11 | 12 | const iframeDoc = iframe.contentWindow.document; 13 | 14 | let aspectRatioIndex = 0; 15 | let aspectRatio = aspectRatios[aspectRatioIndex]; 16 | let width = minWidth; 17 | let height = Math.round(width / aspectRatio); 18 | setStyles(iframe, { 19 | width: width + 'px', 20 | height: height + 'px', 21 | }); 22 | setStyles(iframeDoc.documentElement, {overflow: 'hidden'}); 23 | setStyles(iframeDoc.body, {overflow: 'hidden'}); 24 | 25 | let referenceImg = iframeDoc.createElement('img'); 26 | if ('sizes' in referenceImg) { 27 | referenceImg.srcset = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7 1w'; 28 | referenceImg.sizes = '100vw'; 29 | setStyles(referenceImg, { 30 | position: 'absolute', 31 | top: 0, 32 | left: 0, 33 | display: 'block', 34 | width: 'auto', 35 | 'max-width': 'none', 36 | 'min-width': 0, 37 | height: 'auto', 38 | 'max-height': 'none', 39 | 'min-height': 0, 40 | border: 0, 41 | padding: 0, 42 | }); 43 | iframeDoc.body.appendChild(referenceImg); 44 | } 45 | else { 46 | referenceImg = undefined; 47 | } 48 | 49 | let referenceSource = iframeDoc.createElement('source'); 50 | let referencePicture = iframeDoc.createElement('picture'); 51 | if ('sizes' in referenceSource) { 52 | referenceSource.srcset = referenceImg.srcset; 53 | referenceSource.sizes = '50vw'; 54 | const media = []; 55 | for (let width = minWidth; width <= maxWidth; width += stepSize * 2) { 56 | media.push(width); 57 | } 58 | referenceSource.media = media.map(width => '(width:' + width + 'px)').join(','); 59 | referencePicture.appendChild(referenceSource); 60 | referencePicture.appendChild(referenceImg); 61 | iframeDoc.body.appendChild(referencePicture); 62 | } 63 | else { 64 | referenceSource = undefined; 65 | referencePicture = undefined; 66 | } 67 | 68 | const initTime = Date.now(); 69 | let skipSizeCheck = false; 70 | 71 | function resizeStep(startTime = Date.now()) { 72 | 73 | progress(( 74 | (width - minWidth) 75 | / (maxWidth - minWidth) 76 | / aspectRatios.length 77 | + (aspectRatioIndex / aspectRatios.length) 78 | ), width + 'x' + height); 79 | 80 | let referenceWidth = width; 81 | 82 | // Firefox (46) needs some time to update images based on media queries 83 | if (referenceSource && (width - minWidth) % (stepSize * 2) === 0) { 84 | referenceWidth /= 2; 85 | } 86 | 87 | // Chrome (43) needs some time to update image sizes based on the sizes attribute 88 | if (referenceImg && imageWidth(referenceImg) !== referenceWidth && !skipSizeCheck) { 89 | 90 | // Fix bug in Safari which never resizes the reference image 91 | if (width === minWidth && Date.now() - initTime > 5000) { 92 | skipSizeCheck = true; 93 | } 94 | 95 | // Trigger a reflow for Firefox (46) 96 | setStyles(iframeDoc.body, {display: 'none'}); 97 | iframeDoc.body.offsetHeight; 98 | setStyles(iframeDoc.body, {display: ''}); 99 | 100 | setTimeout(resizeStep, 0); 101 | return; 102 | 103 | } 104 | 105 | let allLoaded = data.reduce( 106 | (loaded, image) => loaded && (!image.dom.img || image.dom.img.complete), 107 | true 108 | ); 109 | if (!allLoaded) { 110 | data.map(image => { 111 | if (image.dom.img) { 112 | image.dom.img.loading = 'eager'; 113 | image.dom.img.decoding = 'sync'; 114 | } 115 | }); 116 | setTimeout(resizeStep, 0); 117 | return; 118 | } 119 | 120 | addDimensions(data, width + 'x' + height); 121 | width += stepSize; 122 | height = Math.round(width / aspectRatio); 123 | if (width > maxWidth) { 124 | aspectRatioIndex++; 125 | if (!aspectRatios[aspectRatioIndex]) { 126 | progress(1, maxWidth + 'x' + Math.round(maxWidth / aspectRatio)); 127 | resolve(); 128 | return; 129 | } 130 | aspectRatio = aspectRatios[aspectRatioIndex]; 131 | width = minWidth; 132 | height = Math.round(width / aspectRatio); 133 | } 134 | setStyles(iframe, { 135 | width: width + 'px', 136 | height: height + 'px', 137 | }); 138 | 139 | // Don’t force the main thread to go under 30 fps 140 | if (Date.now() - startTime > 1000 / 30) { 141 | setTimeout(resizeStep, 0); 142 | return; 143 | } 144 | 145 | resizeStep(startTime); 146 | } 147 | 148 | resizeStep(); 149 | 150 | }); 151 | 152 | } 153 | 154 | function addDimensions(data, viewport) { 155 | data.forEach(image => addDimension(image, viewport)); 156 | } 157 | 158 | function addDimension(image, viewport) { 159 | image.dimensions = image.dimensions || {}; 160 | if (!image.dom.img) { 161 | return; 162 | } 163 | image.dimensions[viewport] = imageWidth(image.dom.img); 164 | } 165 | 166 | function imageWidth(img) { 167 | if (!img.clientWidth) { 168 | return 0; 169 | } 170 | 171 | if (!img.naturalWidth || !img.naturalHeight) { 172 | return img.width; 173 | } 174 | 175 | const width = img.width; 176 | const height = img.height; 177 | const style = getComputedStyle(img); 178 | const ratio = img.naturalWidth / img.naturalHeight; 179 | 180 | if ( 181 | (['contain', 'scale-down'].includes(style.objectFit) && height * ratio < width) 182 | || (style.objectFit === 'cover' && height * ratio > width) 183 | ) { 184 | return Math.round(height * ratio); 185 | } 186 | 187 | return width; 188 | } 189 | -------------------------------------------------------------------------------- /src/collector/readImages.js: -------------------------------------------------------------------------------- 1 | import jpegQuality from '../util/jpegQuality'; 2 | 3 | export default function readImages(document, data, progress) { 4 | 5 | return Promise.resolve().then(() => { 6 | 7 | let urls = []; 8 | 9 | data.forEach(({data: image}) => { 10 | if (image.img && image.img.src) { 11 | urls.push(image.img.src); 12 | } 13 | if (image.img && image.img.srcset) { 14 | image.img.srcset.forEach(({src}) => { 15 | urls.push(src); 16 | }); 17 | } 18 | image.sources.forEach(({srcset}) => { 19 | srcset.forEach(({src}) => { 20 | urls.push(src); 21 | }); 22 | }); 23 | }); 24 | 25 | urls = urls.filter((url, index) => urls.indexOf(url) === index); 26 | 27 | let images = {}; 28 | 29 | urls.forEach(url => { 30 | let image = { 31 | url: resolveUrl(document, url), 32 | element: new Image(), 33 | }; 34 | images[url] = image; 35 | }); 36 | 37 | return Promise.all(Object.keys(images).map(key => { 38 | 39 | const image = images[key]; 40 | 41 | return loadImageAsBlob(image.url) 42 | 43 | .then(blob => { 44 | const loadedPromise = new Promise(resolve => { 45 | image.element.onload = image.element.onerror = () => resolve(); 46 | }); 47 | image.blob = blob; 48 | image.element.src = URL.createObjectURL(blob); 49 | return loadedPromise; 50 | }) 51 | 52 | // Fall back to classc image loading 53 | .catch(() => { 54 | const loadedPromise = new Promise(resolve => { 55 | image.element.onload = image.element.onerror = () => resolve(); 56 | }); 57 | image.element.src = image.url; 58 | return loadedPromise; 59 | }) 60 | 61 | .then(() => { 62 | readImage(image); 63 | return readQuality(image); 64 | }) 65 | 66 | // Fail silently 67 | .catch(() => {}) 68 | 69 | .then(() => { 70 | progress( 71 | Object.keys(images).reduce( 72 | (count, key) => count + (images[key].element ? 0 : 1), 73 | 0 74 | ) / (Object.keys(images).length || 1), 75 | Object.keys(images).length, 76 | image 77 | ); 78 | }) 79 | 80 | // Clean data 81 | .then(() => { 82 | delete image.blob; 83 | delete image.element; 84 | }) 85 | 86 | ; 87 | 88 | })).then(() => images); 89 | 90 | }) 91 | .then(images => { 92 | 93 | data.forEach(image => { 94 | image.images = {}; 95 | if (image.data.img && image.data.img.src) { 96 | image.images[image.data.img.src] = images[image.data.img.src]; 97 | } 98 | if (image.data.img && image.data.img.srcset) { 99 | image.data.img.srcset.forEach(({src}) => { 100 | image.images[src] = images[src]; 101 | }); 102 | } 103 | image.data.sources.forEach(({srcset}) => { 104 | srcset.forEach(({src}) => { 105 | image.images[src] = images[src]; 106 | }); 107 | }); 108 | }); 109 | 110 | }); 111 | 112 | } 113 | 114 | function resolveUrl(document, url) { 115 | let link = document.createElement('a'); 116 | link.href = url; 117 | return link.href + ''; 118 | } 119 | 120 | function readImage(image) { 121 | image.size = { 122 | width: image.element.naturalWidth, 123 | height: image.element.naturalHeight, 124 | }; 125 | if (image.blob && image.blob.type) { 126 | image.type = image.blob.type; 127 | } 128 | else { 129 | let extension = image.url 130 | .split('#')[0] 131 | .split('?')[0] 132 | .split('/').pop() 133 | .split('.').pop() 134 | .toLowerCase(); 135 | if (extension === 'jpg') { 136 | extension = 'jpeg'; 137 | } 138 | if (extension === 'svg') { 139 | extension = 'svg+xml'; 140 | } 141 | image.type = extension ? 'image/' + extension : undefined; 142 | } 143 | image.hash = getImageHash(image.element); 144 | } 145 | 146 | function getImageHash(image) { 147 | 148 | const size = 8; 149 | const depth = 16; 150 | 151 | let data; 152 | try { 153 | let empty = true; 154 | data = Array.from( 155 | stepDownResize(image, size).getImageData(0, 0, size, size).data 156 | ).reduce( 157 | (str, val, i, arr) => { 158 | if (val) { 159 | empty = false; 160 | } 161 | if ((i + 1) % 4) { 162 | var opacity = arr[i + (4 - ((i + 1) % 4))] / 255; 163 | val *= opacity; 164 | if (i % 4 === i % 8) { 165 | val += 255 * (1 - opacity); 166 | } 167 | str += Math.round(val * ((depth - 1) / 255)).toString(depth); 168 | } 169 | return str; 170 | }, 171 | '' 172 | ); 173 | if (empty) { 174 | data = undefined; 175 | } 176 | } 177 | catch (e) { 178 | data = undefined; 179 | } 180 | 181 | return data; 182 | } 183 | 184 | function stepDownResize(image, targetSize) { 185 | 186 | const imageSize = Math.max(image.naturalWidth || 0, image.naturalHeight || 0, targetSize); 187 | let size = targetSize; 188 | while (size * 2 < imageSize) { 189 | size *= 2; 190 | } 191 | 192 | const ctx = createCanvasCtx(size); 193 | const ctxTmp = createCanvasCtx(size); 194 | ctx.drawImage(image, 0, 0, size, size); 195 | 196 | // Throw tainted canvas security error before resizing 197 | ctx.getImageData(0, 0, 1, 1); 198 | 199 | while (size > targetSize) { 200 | ctxTmp.clearRect(0, 0, size, size); 201 | ctxTmp.drawImage( 202 | ctx.canvas, 203 | 0, 0, size, size, 204 | 0, 0, size, size 205 | ); 206 | ctx.clearRect(0, 0, size / 2, size / 2); 207 | ctx.drawImage( 208 | ctxTmp.canvas, 209 | 0, 0, size, size, 210 | 0, 0, size / 2, size / 2 211 | ); 212 | size /= 2; 213 | } 214 | 215 | return ctx; 216 | 217 | } 218 | 219 | function createCanvasCtx(size) { 220 | const canvas = document.createElement('canvas'); 221 | canvas.width = canvas.height = size; 222 | return canvas.getContext('2d', {willReadFrequently: true}); 223 | } 224 | 225 | function loadImageAsBlob(url) { 226 | return Promise.resolve() 227 | .then(() => fetch(url, { 228 | headers: { 229 | 'Accept': 'image/*,*/*;q=0.8', 230 | 'X-Requested-With': 'XMLHttpRequest', 231 | }, 232 | })) 233 | .then(response => { 234 | if (response.status >= 200 && response.status < 300) { 235 | return response; 236 | } 237 | else { 238 | let error = new Error(response.statusText); 239 | error.response = response; 240 | throw error; 241 | } 242 | }) 243 | .then(response => response.blob()); 244 | } 245 | 246 | function readQuality(image) { 247 | return Promise.resolve() 248 | .then(() => { 249 | return getQualityFromBlob(image.blob); 250 | }) 251 | .catch((err) => { 252 | return undefined; 253 | }) 254 | .then(quality => { 255 | image.quality = quality; 256 | }); 257 | } 258 | 259 | function getQualityFromBlob(blob) { 260 | if (!blob) { 261 | throw new Error('Missing blob'); 262 | } 263 | if (blob.type === 'image/jpeg') { 264 | return getJpegQualityFromBlob(blob); 265 | } 266 | if (blob.type === 'image/png' || blob.type === 'image/gif') { 267 | return 100; 268 | } 269 | throw new Error('Unable to read quality from image type ' + blob.type); 270 | } 271 | 272 | function getJpegQualityFromBlob(blob) { 273 | return readBlobAsArrayBuffer(blob).then(buffer => { 274 | return jpegQuality(buffer); 275 | }); 276 | } 277 | 278 | function readBlobAsArrayBuffer(blob) { 279 | var reader = new FileReader(); 280 | reader.readAsArrayBuffer(blob); 281 | return new Promise(function(resolve, reject) { 282 | reader.onload = function() { 283 | resolve(reader.result); 284 | }; 285 | reader.onerror = function() { 286 | reject(reader.error); 287 | }; 288 | }); 289 | } 290 | -------------------------------------------------------------------------------- /src/collector/readMarkup.js: -------------------------------------------------------------------------------- 1 | export default function readMarkup(image) { 2 | image.markup = readNode(image.dom.picture || image.dom.img); 3 | return image; 4 | } 5 | 6 | function readNode(node) { 7 | return { 8 | tag: node.tagName.toLowerCase(), 9 | attributes: readAttributes(node), 10 | children: Array.from(node.children).map(readNode), 11 | }; 12 | } 13 | 14 | function readAttributes(node) { 15 | return Array.from(node.attributes).map(({name, value}) => ({name, value})); 16 | } 17 | -------------------------------------------------------------------------------- /src/collector/readMediaQueries.js: -------------------------------------------------------------------------------- 1 | export default function readMediaQueries(document, data) { 2 | 3 | return Promise.resolve().then(() => { 4 | 5 | let queries = {}; 6 | 7 | [...document.styleSheets].forEach(function(sheet) { 8 | 9 | try { 10 | if (!sheet.cssRules.length) { 11 | return; 12 | } 13 | } 14 | catch(e) { 15 | // Ignore access errors 16 | return; 17 | } 18 | 19 | for (var i = 0; i < sheet.cssRules.length; i++) { 20 | try { 21 | parseRule(sheet.cssRules[i], queries); 22 | } 23 | catch(e) { 24 | // Ignore errors. 25 | } 26 | } 27 | 28 | }); 29 | 30 | data.mediaQueries = Object.keys(queries); 31 | 32 | }); 33 | 34 | } 35 | 36 | function parseRule(rule, queries) { 37 | 38 | if (rule.media && rule.media.length) { 39 | [...rule.media].forEach(media => queries[media] = true); 40 | } 41 | 42 | if (rule.cssRules) { 43 | for (var i = 0; i < rule.cssRules.length; i++) { 44 | parseRule(rule.cssRules[i], queries); 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/linter.js: -------------------------------------------------------------------------------- 1 | //require("babelify/polyfill"); 2 | import linter from './linter/index'; 3 | import reporter from './reporter'; 4 | 5 | let data = JSON.parse(localStorage.respImageLintData); 6 | data = linter(data); 7 | let report = reporter(data); 8 | 9 | document.body.appendChild(report); 10 | -------------------------------------------------------------------------------- /src/linter/descriptors/duplicate.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(item) { 4 | let descriptors = item.srcset.map( 5 | ({descriptor}) => (descriptor || '1x').replace(/(?:\s+|^)\d+h(?:\s+|$)/, '') 6 | ); 7 | descriptors.forEach((descriptor, index) => { 8 | if (descriptors.indexOf(descriptor) !== index) { 9 | error(__filename, item, { 10 | descriptor, 11 | image1: item.srcset[descriptors.indexOf(descriptor)].src, 12 | image2: item.srcset[index].src, 13 | }); 14 | } 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/linter/descriptors/duplicate.md: -------------------------------------------------------------------------------- 1 | # Descriptors must be unique 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | 8 | ``` 9 | 10 | ## Bad 11 | 12 | ```html 13 | 14 | 15 | 16 | ``` 17 | 18 | ## Error template 19 | 20 | The descriptor {{descriptor}} appears twice in the same `srcset` attribute ([{{image1}}]({{image1Url}}) and [{{image2}}]({{image2Url}})). 21 | -------------------------------------------------------------------------------- /src/linter/descriptors/index.js: -------------------------------------------------------------------------------- 1 | import allSources from '../../util/allSources'; 2 | import duplicate from './duplicate'; 3 | import malformed from './malformed'; 4 | import mixed from './mixed'; 5 | import xAndSizes from './xAndSizes'; 6 | import wMissingSizes from './wMissingSizes'; 7 | import wrongSize from './wrongSize'; 8 | import wrongX from './wrongX'; 9 | 10 | export default function(image, data) { 11 | allSources(image).forEach(item => { 12 | duplicate(item); 13 | malformed(item); 14 | mixed(item); 15 | xAndSizes(item); 16 | wrongSize(item, image.images); 17 | wrongX(item, image.images); 18 | }); 19 | wMissingSizes(image, data.mediaQueries); 20 | } 21 | -------------------------------------------------------------------------------- /src/linter/descriptors/malformed.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(item) { 4 | item.srcset.forEach(({descriptor}) => { 5 | if ( 6 | descriptor 7 | && !descriptor.match(/^\d+(?:\.\d+)?x$/) 8 | && !descriptor.match(/^\d+w$/) 9 | ) { 10 | error(__filename, item, { 11 | descriptor, 12 | }); 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/linter/descriptors/malformed.md: -------------------------------------------------------------------------------- 1 | # Malformed descriptor 2 | 3 | The syntax of the descriptors is defined in the [spec](https://html.spec.whatwg.org/multipage/embedded-content.html#image-candidate-string). 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | ``` 11 | 12 | ## Bad 13 | 14 | ```html 15 | 16 | 17 | 18 | 19 | 20 | ``` 21 | 22 | ## Error template 23 | 24 | Descriptor `{{descriptor}}` is invalid. 25 | -------------------------------------------------------------------------------- /src/linter/descriptors/mixed.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(item) { 4 | let descriptors = item.srcset.map( 5 | ({descriptor}) => (descriptor || '1x').replace(/(?:\s+|^)\d+h(?:\s+|$)/, '') 6 | ); 7 | if ( 8 | descriptors.length > 1 9 | && descriptors.map( 10 | desc => desc.substr(-1) 11 | ).reduce( 12 | (a, b) => a === b ? a : false 13 | ) === false 14 | ) { 15 | error(__filename, item, { 16 | descriptors: descriptors.join(', '), 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/linter/descriptors/mixed.md: -------------------------------------------------------------------------------- 1 | # X and W descriptors must not be mixed in one srcset attribute 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | 8 | ``` 9 | 10 | ## Bad 11 | 12 | ```html 13 | 14 | 15 | ``` 16 | 17 | ## Error template 18 | 19 | X and W descriptors are mixed: `{{descriptors}}`. 20 | -------------------------------------------------------------------------------- /src/linter/descriptors/wMissingSizes.js: -------------------------------------------------------------------------------- 1 | import allSources from '../../util/allSources'; 2 | import error from '../../util/error'; 3 | import computeSizesAttribute from '../../util/computeSizesAttribute'; 4 | 5 | export default function(image, mediaQueries) { 6 | allSources(image).forEach(item => { 7 | const errorDescriptors = []; 8 | item.srcset.forEach(({descriptor}) => { 9 | if (descriptor && descriptor.substr(-1) === 'w' && !item.sizes.length) { 10 | errorDescriptors.push(descriptor); 11 | } 12 | }); 13 | if (errorDescriptors.length) { 14 | error(__filename, item, { 15 | descriptors: errorDescriptors.join(', '), 16 | sizesSuggestion: computeSizesAttribute(image.dimensions, mediaQueries.bySize), 17 | }); 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/linter/descriptors/wMissingSizes.md: -------------------------------------------------------------------------------- 1 | # Sizes attribute must be set if W descriptors are used 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | 8 | ``` 9 | 10 | ## Bad 11 | 12 | ```html 13 | 14 | ``` 15 | 16 | ## Error template 17 | 18 | Descriptors `{{descriptors}}` are used but the `sizes` attribute is missing. 19 | 20 | Try using `sizes="{{sizesSuggestion}}"` 21 | -------------------------------------------------------------------------------- /src/linter/descriptors/wrongSize.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(item, images) { 4 | item.srcset.forEach(({src, descriptor}) => { 5 | (descriptor && descriptor.split(/\s+/) || []).forEach(descriptor => { 6 | if (( 7 | descriptor.substr(-1) === 'w' 8 | && images[src].size.width 9 | && parseInt(descriptor) !== images[src].size.width 10 | ) || ( 11 | descriptor.substr(-1) === 'h' 12 | && images[src].size.height 13 | && parseInt(descriptor) !== images[src].size.height 14 | )) { 15 | error(__filename, item, { 16 | descriptor, 17 | image: src, 18 | }); 19 | } 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/linter/descriptors/wrongSize.md: -------------------------------------------------------------------------------- 1 | # W descriptor doesn’t match the image size 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | ``` 8 | 9 | ## Bad 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | ## Error template 16 | 17 | Descriptor `{{descriptor}}` doesn’t match the image size of {{imageSize}} from [{{image}}]({{imageUrl}}). 18 | -------------------------------------------------------------------------------- /src/linter/descriptors/wrongX.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | const threshold = 0.02; 4 | const thresholdPx = 2; 5 | 6 | export default function(item, images) { 7 | 8 | const base = item.srcset.filter( 9 | ({descriptor}) => !descriptor || descriptor.substr(-1) === 'x' 10 | ).sort( 11 | ({descriptor: a}, {descriptor: b}) => Math.abs(parseFloat(a || 1) - 1) - Math.abs(parseFloat(b || 1) - 1) 12 | )[0]; 13 | 14 | if (!base) { 15 | return; 16 | } 17 | 18 | let baseWidth = images[base.src].size.width; 19 | let baseX = parseFloat(base.descriptor || 1); 20 | 21 | if (baseX !== 1 && item.src) { 22 | baseWidth = images[item.src].size.width; 23 | baseX = 1; 24 | } 25 | 26 | item.srcset.forEach(({src, descriptor}) => { 27 | if (descriptor && descriptor.substr(-1) !== 'x') { 28 | return; 29 | } 30 | const multiplier = parseFloat(descriptor || 1) / baseX; 31 | if ( 32 | baseWidth * multiplier * (1 - threshold) - thresholdPx > images[src].size.width 33 | || baseWidth * multiplier * (1 + threshold) + thresholdPx < images[src].size.width 34 | ) { 35 | error(__filename, item, { 36 | descriptor, 37 | correctWidth: Math.round(baseWidth * multiplier), 38 | correctDescriptor: Math.round(images[src].size.width / (baseWidth / baseX) * 100) / 100 + 'x', 39 | image: src, 40 | }); 41 | } 42 | }); 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/linter/descriptors/wrongX.md: -------------------------------------------------------------------------------- 1 | # X descriptor doesn’t match the image size 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | ``` 8 | 9 | ## Bad 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | ## Error template 16 | 17 | Descriptor `{{descriptor}}` doesn’t match the image width of {{imageWidth}} from [{{image}}]({{imageUrl}}), the image should be {{correctWidth}} pixels wide or `{{correctDescriptor}}` should be used as descriptor. 18 | -------------------------------------------------------------------------------- /src/linter/descriptors/xAndSizes.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(item) { 4 | item.srcset.forEach(({descriptor}) => { 5 | if ((!descriptor || descriptor.substr(-1) === 'x') && item.sizes.length) { 6 | error(__filename, item, { 7 | descriptor, 8 | }); 9 | } 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/linter/descriptors/xAndSizes.md: -------------------------------------------------------------------------------- 1 | # X descriptor must not be used if sizes attribute is set 2 | 3 | ## Good 4 | 5 | ```html 6 | 7 | 8 | ``` 9 | 10 | ## Bad 11 | 12 | ```html 13 | 14 | ``` 15 | 16 | ## Error template 17 | 18 | X descriptor `{{descriptor}}` is used but the `sizes` attribute is set. 19 | -------------------------------------------------------------------------------- /src/linter/fallbacks/index.js: -------------------------------------------------------------------------------- 1 | import src from './src'; 2 | 3 | export default function(image) { 4 | src(image); 5 | } 6 | -------------------------------------------------------------------------------- /src/linter/fallbacks/src.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(image) { 4 | if (image.data.img && !image.data.img.src) { 5 | error(__filename, image.data.img); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/linter/fallbacks/src.md: -------------------------------------------------------------------------------- 1 | # Missing `src` attribute 2 | 3 | Older browsers or other user agents which don’t understand the `srcset` attribute should get a fallback image. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | ## Bad 12 | 13 | ```html 14 | 15 | ``` 16 | -------------------------------------------------------------------------------- /src/linter/images/differentContent.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import hashDistance from '../../util/hashDistance'; 3 | 4 | const threshold = 1 / 25; 5 | 6 | export default function(item, images) { 7 | let sources = []; 8 | if (item.src) { 9 | sources.push(item.src); 10 | } 11 | item.srcset.forEach(({src}) => sources.push(src)); 12 | const errorImages = {}; 13 | sources.forEach(src => { 14 | if (!images[src].hash) { 15 | return; 16 | } 17 | sources.forEach(src2 => { 18 | if ( 19 | src2 !== src 20 | && images[src2].hash 21 | && images[src].hash !== images[src2].hash 22 | && hashDistance(images[src].hash, images[src2].hash) > threshold 23 | && !errorImages[src] 24 | && !errorImages[src2] 25 | ) { 26 | error(__filename, item, { 27 | image1: src, 28 | hash1: images[src].hash, 29 | image2: src2, 30 | hash2: images[src2].hash, 31 | distance: Math.round(hashDistance(images[src].hash, images[src2].hash) * 100) + '%', 32 | }); 33 | errorImages[src] = true; 34 | errorImages[src2] = true; 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/linter/images/differentContent.md: -------------------------------------------------------------------------------- 1 | # Images in `srcset` attribute must not be different 2 | 3 | The `srcset` attribute isn’t for art direction, so the images must therefore only differ in dimensions not in the image contents. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | ## Bad 12 | 13 | ```html 14 | 15 | ``` 16 | 17 | ## Error template 18 | 19 | It seems the image [{{image1}}]({{image1Url}}) doesn’t show the same contents as [{{image2}}]({{image2Url}}) does, the determined difference is {{distance}}. 20 | -------------------------------------------------------------------------------- /src/linter/images/differentRatio.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import sameRatio from '../../util/sameRatio'; 3 | 4 | export default function(item, images) { 5 | let sources = []; 6 | if (item.src) { 7 | sources.push(item.src); 8 | } 9 | item.srcset.forEach(({src}) => sources.push(src)); 10 | const errorImages = {}; 11 | sources.forEach(src => { 12 | if (!images[src].size.width || !images[src].size.height) { 13 | return; 14 | } 15 | sources.forEach(src2 => { 16 | if ( 17 | src2 !== src 18 | && images[src2].size.width 19 | && images[src2].size.height 20 | && !errorImages[src] 21 | && !errorImages[src2] 22 | && !sameRatio(images[src].size, images[src2].size) 23 | ) { 24 | error(__filename, item, { 25 | image1: src, 26 | ratio1: Math.round(100 / images[src].size.width * images[src].size.height) + '%', 27 | image2: src2, 28 | ratio2: Math.round(100 / images[src2].size.width * images[src2].size.height) + '%', 29 | }); 30 | errorImages[src] = true; 31 | errorImages[src2] = true; 32 | } 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/linter/images/differentRatio.md: -------------------------------------------------------------------------------- 1 | # Images in `srcset` attribute must not have different aspect ratios 2 | 3 | The `srcset` attribute isn’t for art direction, so the images must therefore only differ in dimensions not in the ratio. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | ## Bad 12 | 13 | ```html 14 | 15 | ``` 16 | 17 | ## Error template 18 | 19 | The image [{{image1}}]({{image1Url}}) has an aspect ratio of {{ratio1}} ({{image1Width}}x{{image1Height}}) but the ratio of [{{image2}}]({{image2Url}}) is {{ratio2}} ({{image2Width}}x{{image2Height}}). 20 | -------------------------------------------------------------------------------- /src/linter/images/index.js: -------------------------------------------------------------------------------- 1 | import allSources from '../../util/allSources'; 2 | import differentContent from './differentContent'; 3 | import differentRatio from './differentRatio'; 4 | import missingFittingSrc from './missingFittingSrc'; 5 | import sameContent from './sameContent'; 6 | import sizesAuto from './sizesAuto'; 7 | import sizesAutoLazy from './sizesAutoLazy'; 8 | import wrongSizes from './wrongSizes'; 9 | 10 | export default function(image, data) { 11 | allSources(image).forEach(item => { 12 | differentRatio(item, image.images); 13 | differentContent(item, image.images); 14 | }); 15 | missingFittingSrc(image); 16 | sameContent(image); 17 | sizesAuto(image); 18 | sizesAutoLazy(image); 19 | wrongSizes(image, data.mediaQueries); 20 | } 21 | -------------------------------------------------------------------------------- /src/linter/images/missingFittingSrc.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import allSources from '../../util/allSources'; 3 | import mediaToStringArray from '../../util/mediaToStringArray'; 4 | import stripViewportQueries from '../../util/stripViewportQueries'; 5 | import mediaMatchesViewport from '../../util/mediaMatchesViewport'; 6 | import computeSrcsetWidths from '../../util/computeSrcsetWidths'; 7 | 8 | const megapixelThreshold = 0.2; 9 | const megapixelGap = 0.75; 10 | const recommendedMinWidth = 256; 11 | const recommendedMaxWidth = 2048; 12 | const commonDevices = [ 13 | // Google Lighthouse, see https://github.com/GoogleChrome/lighthouse/blob/b64b3534542c9dcaabb33d40b84ed7c93eefbd7d/core/config/constants.js#L16-L20 14 | { width: 412, dpr: 1.75 }, 15 | // Many different smartphones with a device resolution of 1080x… 16 | { width: 360, dpr: 3 }, 17 | // Most popular desktop @1x screen size 18 | { width: 1920, dpr: 1 }, 19 | ]; 20 | 21 | export default function(image) { 22 | 23 | const errorItems = []; 24 | const dimensionsBySource = []; 25 | const viewportWidths = {} 26 | 27 | Object.keys(image.dimensions).forEach(viewport => { 28 | 29 | viewportWidths[viewport.split('x')[0]] = true; 30 | 31 | let imageWidth = image.dimensions[viewport]; 32 | const sourceMatched = {}; 33 | allSources(image).forEach((item, itemIndex) => { 34 | 35 | const categories = mediaToStringArray( 36 | stripViewportQueries(item.media) 37 | ).map( 38 | media => (item.type || 'image/*') + '|' + media 39 | ); 40 | 41 | if (categories.reduce((result, category) => result || sourceMatched[category], false)) { 42 | return; 43 | } 44 | 45 | if (item.type === 'image/svg+xml' || !imageWidth) { 46 | return; 47 | } 48 | 49 | if (item.media && !mediaMatchesViewport(item.media, viewport)) { 50 | return; 51 | } 52 | categories.forEach(category => { 53 | sourceMatched[category] = true; 54 | }); 55 | dimensionsBySource[itemIndex] = dimensionsBySource[itemIndex] || {}; 56 | dimensionsBySource[itemIndex][viewport] = imageWidth; 57 | 58 | let srcs = item.srcset.map(({src}) => src); 59 | 60 | if (item.src && !item.srcset.filter(({descriptor = '1x'}) => 61 | descriptor.substr(-1) !== 'x' || descriptor === '1x' 62 | ).length) { 63 | srcs.push(item.src); 64 | } 65 | 66 | // Skip if one candidate is a vector image 67 | if (srcs.map(src => image.images[src].type).indexOf('image/svg+xml') !== -1) { 68 | return; 69 | } 70 | 71 | const ratio = ((image.images[srcs[0]] && image.images[srcs[0]].size.width) ? image.images[srcs[0]].size.height / image.images[srcs[0]].size.width : 1) || 1; 72 | 73 | let nearbyWidth = srcs 74 | .map(src => image.images[src].size.width) 75 | .filter(Boolean) 76 | .sort((a, b) => { 77 | [a, b] = [a, b].map(width => Math.abs(((imageWidth * imageWidth * ratio) - (width * width * ratio)) / 1000000)); 78 | return a - b; 79 | })[0]; 80 | 81 | if (!nearbyWidth) { 82 | return; 83 | } 84 | 85 | const distance = 1 - ( 86 | imageWidth < nearbyWidth 87 | ? imageWidth / nearbyWidth 88 | : nearbyWidth / imageWidth 89 | ); 90 | 91 | const megapixelDistance = Math.abs(((imageWidth * imageWidth * ratio) - (nearbyWidth * nearbyWidth * ratio)) / 1000000); 92 | 93 | if (megapixelDistance > megapixelGap / 2 && (nearbyWidth < recommendedMaxWidth || imageWidth < recommendedMaxWidth)) { 94 | errorItems[itemIndex] = errorItems[itemIndex] || {}; 95 | errorItems[itemIndex][viewport] = { 96 | viewport, 97 | imageWidth, 98 | nearbyWidth, 99 | distance: Math.round(distance * 100) + '%', 100 | megapixelDistance: Math.round(megapixelDistance * 100) / 100 + '', 101 | }; 102 | } 103 | 104 | }); 105 | 106 | }); 107 | 108 | allSources(image).forEach((item, itemIndex) => { 109 | 110 | if (!errorItems[itemIndex]) { 111 | return; 112 | } 113 | 114 | const firstItem = errorItems[itemIndex][ 115 | [ 116 | '1280x720', '1440x810', '1000x563', '320x427', '480x270', '1920x1080', 117 | Object.keys(errorItems[itemIndex])[0], 118 | ].filter( 119 | viewport => errorItems[itemIndex][viewport] 120 | )[0] 121 | ]; 122 | 123 | let viewportRanges = []; 124 | let lastViewWidth = 0; 125 | 126 | Object.keys(errorItems[itemIndex]).forEach(viewport => { 127 | let viewWidth = viewport.split('x')[0]; 128 | if (Math.abs(lastViewWidth - viewWidth) > 20) { 129 | viewportRanges.push([viewport, viewport]); 130 | } 131 | viewportRanges[viewportRanges.length - 1][1] = viewport; 132 | lastViewWidth = viewWidth; 133 | }); 134 | 135 | for (let i = 0; i < viewportRanges.length; i++) { 136 | for (let j = i + 1; j < viewportRanges.length; j++) { 137 | if ( 138 | viewportRanges[i] 139 | && viewportRanges[j] 140 | && viewportRanges[i][0].split('x')[0] === viewportRanges[j][0].split('x')[0] 141 | && viewportRanges[i][1].split('x')[0] === viewportRanges[j][1].split('x')[0] 142 | ) { 143 | viewportRanges[i][1] = viewportRanges[j][1]; 144 | viewportRanges[j] = undefined; 145 | } 146 | } 147 | } 148 | 149 | viewportRanges = viewportRanges.filter(Boolean); 150 | 151 | const srcPaths = [...(item.srcset || []).map(({ src }) => src)]; 152 | if (item.src && !srcPaths.includes(item.src)) { 153 | srcPaths.push(item.src); 154 | } 155 | 156 | const srcSizes = srcPaths.filter(src => !!image.images[src]).map(src => image.images[src].size); 157 | const recommendationWithExisting = buildRecommendation(dimensionsBySource[itemIndex], srcSizes, Object.keys(viewportWidths).length, true); 158 | const recommendationWithoutExisting = buildRecommendation(dimensionsBySource[itemIndex], srcSizes, Object.keys(viewportWidths).length, false); 159 | 160 | error(__filename, item, { 161 | viewport: firstItem.viewport.replace(/x/g, '×'), 162 | imageWidth: firstItem.imageWidth, 163 | nearbyWidth: firstItem.nearbyWidth, 164 | distance: firstItem.distance, 165 | megapixelDistance: firstItem.megapixelDistance, 166 | viewportRanges: viewportRanges.map(range => range[0] === range[1] ? range[0] : range.join('–')).join(', ').replace(/x/g, '×'), 167 | recommendation: '
    ' + recommendationWithExisting + (recommendationWithExisting !== recommendationWithoutExisting ? '
    Or alternatively, disregarding existing image files, the following:
    ' + recommendationWithoutExisting : ''), 168 | recommendationContext: image.data.img === item ? '<img srcset="…">' : 'the ' + humanReadableIndex(itemIndex) + ' <source srcset="…">', 169 | }); 170 | 171 | }); 172 | 173 | } 174 | 175 | function humanReadableIndex(index) { 176 | index++; 177 | const ordinal = new Intl.PluralRules('en-US', { type: 'ordinal' }).select(index); 178 | const suffixes = { 179 | one: 'st', 180 | two: 'nd', 181 | few: 'rd', 182 | other: 'th', 183 | }; 184 | 185 | return index + suffixes[ordinal]; 186 | } 187 | 188 | function buildRecommendation(dimensions, sizes, viewportsCount, includeExisting) { 189 | const ratio = (sizes[0] && sizes[0].width && sizes[0].height) ? sizes[0].height / sizes[0].width : 1; 190 | return computeSrcsetWidths(dimensions, ratio, viewportsCount, includeExisting ? sizes.map(size => size.width).filter(Boolean) : [], { 191 | recommendedMinWidth, 192 | recommendedMaxWidth, 193 | megapixelThreshold, 194 | megapixelGap, 195 | commonDevices, 196 | }).map(width => width + '×' + Math.round(width * ratio) + '').join(', '); 197 | } 198 | -------------------------------------------------------------------------------- /src/linter/images/missingFittingSrc.md: -------------------------------------------------------------------------------- 1 | # A fitting image source should be available for all screen sizes 2 | 3 | Loading a large image and display it much smaller should be avoided to save bandwidth. Loading a small image and display it much larger should be avoided to prevent pixelated artifacts. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 15 | ``` 16 | 17 | ## Bad 18 | 19 | ```html 20 | 21 | 27 | ``` 28 | 29 | ## Error template 30 | 31 | At a viewport of {{viewport}} the image was displayed {{imageWidth}} pixels wide, but the closest provided image has a width of {{nearbyWidth}} which is {{distance}} ({{megapixelDistance}} megapixels) off. The affected viewports are {{viewportRanges}}. 32 | 33 | Try using the following image sizes in {{recommendationContext}} instead: {{recommendation}} 34 | -------------------------------------------------------------------------------- /src/linter/images/sameContent.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import allSources from '../../util/allSources'; 3 | import hashDistance from '../../util/hashDistance'; 4 | import sameRatio from '../../util/sameRatio'; 5 | 6 | const threshold = 1 / 16; 7 | 8 | export default function(image) { 9 | 10 | const images = image.images; 11 | 12 | let ignoreFollowing = false; 13 | const sourcesByType = {}; 14 | allSources(image).forEach(item => { 15 | 16 | if (ignoreFollowing) { 17 | return; 18 | } 19 | 20 | let sources = []; 21 | if (item.src && !item.srcset.length) { 22 | sources.push(item.src); 23 | } 24 | item.srcset.forEach(({src}) => sources.push(src)); 25 | 26 | let largestSource = sources.sort( 27 | (a, b) => images[b].size.width - images[a].size.width 28 | ).reduce( 29 | (result, src) => result || (images[src].size.width && images[src].hash && src), 30 | false 31 | ); 32 | 33 | if (!largestSource) { 34 | return; 35 | } 36 | 37 | let type = item.type || 'image/*'; 38 | sourcesByType[type] = sourcesByType[type] || []; 39 | sourcesByType[type].push({ 40 | src: largestSource, 41 | highDpi: isDpiQuery(item.media, true), 42 | lowDpi: isDpiQuery(item.media, false), 43 | }); 44 | 45 | if (type === 'image/*' && !item.media) { 46 | ignoreFollowing = true; 47 | } 48 | 49 | }); 50 | 51 | const errorImages = {}; 52 | 53 | Object.keys(sourcesByType).forEach(type => { 54 | sourcesByType[type].forEach(({src, highDpi, lowDpi}, index) => { 55 | sourcesByType[type].forEach(({src: src2, highDpi: highDpi2, lowDpi: lowDpi2}, index2) => { 56 | if ( 57 | index2 !== index 58 | && src !== src2 59 | && hashDistance(images[src].hash, images[src2].hash) < threshold 60 | && sameRatio(images[src].size, images[src2].size) 61 | && !errorImages[src] 62 | && !errorImages[src2] 63 | && !(images[src].quality && images[src2].quality && ( 64 | (highDpi && !highDpi2 && images[src].quality < images[src2].quality - 10) 65 | || (!highDpi && highDpi2 && images[src2].quality < images[src].quality - 10) 66 | || (lowDpi && !lowDpi2 && images[src2].quality < images[src].quality - 10) 67 | || (!lowDpi && lowDpi2 && images[src].quality < images[src2].quality - 10) 68 | )) 69 | ) { 70 | error(__filename, image.data, { 71 | image1: src, 72 | hash1: images[src].hash, 73 | image2: src2, 74 | hash2: images[src2].hash, 75 | distance: Math.round(hashDistance(images[src].hash, images[src2].hash) * 100) + '%', 76 | }); 77 | errorImages[src] = true; 78 | errorImages[src2] = true; 79 | } 80 | }); 81 | }); 82 | }); 83 | 84 | } 85 | 86 | function isDpiQuery(media, highDpi) { 87 | if (!media || typeof media === 'string') { 88 | return false; 89 | } 90 | return !!media.filter(({type, expressions, inverse}) => { 91 | let matches = false; 92 | expressions.filter( 93 | ({feature}) => feature === 'resolution' 94 | ).forEach(({modifier, value}) => { 95 | if (computeDppx(value) > 1.1 && ( 96 | (highDpi && modifier === 'min' && !inverse) 97 | || (highDpi && modifier === 'max' && inverse) 98 | || (!highDpi && modifier === 'max' && !inverse) 99 | || (!highDpi && modifier === 'min' && inverse) 100 | )) { 101 | matches = true; 102 | } 103 | }); 104 | return matches; 105 | }).length; 106 | } 107 | 108 | function computeDppx(value) { 109 | 110 | if (!value) { 111 | return undefined; 112 | } 113 | 114 | if (value.match(/^\d*\.?\d+dpi$/)) { 115 | return parseFloat(value) / 96; 116 | } 117 | 118 | if (value.match(/^\d*\.?\d+dpcm$/)) { 119 | return parseFloat(value) / 96 * 2.54; 120 | } 121 | 122 | if (value.match(/^\d*\.?\d+dppx$/)) { 123 | return parseFloat(value); 124 | } 125 | 126 | return undefined; 127 | } 128 | -------------------------------------------------------------------------------- /src/linter/images/sameContent.md: -------------------------------------------------------------------------------- 1 | # Images in different `` elements shouldn’t be the same 2 | 3 | The `` element should only be used for art direction and format-based selection. For providing multiple resolutions of the same image use the `srcset` attribute instead. [More information on CSS-Tricks](https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-srcset/). 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ``` 21 | 22 | ## Bad 23 | 24 | ```html 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ``` 34 | 35 | ## Error template 36 | 37 | It seems the image [{{image1}}]({{image1Url}}) shows the same contents as [{{image2}}]({{image2Url}}) does and it has the same aspect ratio and format. 38 | -------------------------------------------------------------------------------- /src/linter/images/sizesAuto.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import allMarkupSources from '../../util/allMarkupSources'; 3 | import allSources from '../../util/allSources'; 4 | 5 | export default function(image) { 6 | 7 | const markupSources = allMarkupSources(image); 8 | 9 | allSources(image).forEach((item, itemIndex) => { 10 | 11 | let hasAuto = false; 12 | let hasError = false; 13 | 14 | item.sizes.forEach(({size, media}, index) => { 15 | 16 | // If the first item is set to auto, ignore it 17 | if (index === 0 && !media && size === 'auto') { 18 | hasAuto = true; 19 | return; 20 | } 21 | 22 | if (size === 'auto') { 23 | hasError = true; 24 | } 25 | 26 | }); 27 | 28 | let sizesAttr = markupSources[itemIndex] && markupSources[itemIndex].attributes.find(attr => attr.name === 'sizes'); 29 | sizesAttr = sizesAttr && sizesAttr.value; 30 | 31 | if (hasAuto && !hasError && !/^auto(,|$)/i.test(sizesAttr)) { 32 | hasError = true; 33 | } 34 | 35 | if (!hasError) { 36 | return; 37 | } 38 | 39 | error(__filename, item, { 40 | sizes: sizesAttr, 41 | }); 42 | 43 | }); 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/linter/images/sizesAuto.md: -------------------------------------------------------------------------------- 1 | # Sizes attribute has to begin with `auto` to enable auto-sizes 2 | 3 | If present, the keyword `auto` must be the first entry and the entire `sizes` attribute must either be `"auto"` or start with `"auto,"` as defined in the [spec](https://html.spec.whatwg.org/multipage/images.html#valdef-sizes-auto). 4 | 5 | ## Good 6 | 7 | ```html 8 | 15 | 22 | ``` 23 | 24 | ## Bad 25 | 26 | ```html 27 | 34 | 41 | 48 | ``` 49 | 50 | ## Error template 51 | 52 | The use of `auto` in the `sizes` attribute `{{sizes}}` is incorrect. 53 | 54 | Try using `sizes="auto"` or `sizes="auto,…"` instead. 55 | -------------------------------------------------------------------------------- /src/linter/images/sizesAutoLazy.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import allSources from '../../util/allSources'; 3 | 4 | export default function(image) { 5 | 6 | let hasAuto = false; 7 | 8 | allSources(image).forEach((item, itemIndex) => { 9 | 10 | item.sizes.forEach(({size, media}, index) => { 11 | if (size === 'auto') { 12 | hasAuto = true; 13 | } 14 | }); 15 | 16 | }); 17 | 18 | if (hasAuto && (image.data.img && image.data.img.loading) !== 'lazy') { 19 | error(__filename, image.data); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/linter/images/sizesAutoLazy.md: -------------------------------------------------------------------------------- 1 | # Auto-sizes cannot be used without lazy-loading 2 | 3 | When using `sizes="auto"` it is required to also use `loading="lazy"`. 4 | 5 | ## Good 6 | 7 | ```html 8 | 15 | ``` 16 | 17 | ## Bad 18 | 19 | ```html 20 | 26 | 33 | ``` 34 | -------------------------------------------------------------------------------- /src/linter/images/wrongSizes.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | import allSources from '../../util/allSources'; 3 | import computeLength from '../../util/computeLength'; 4 | import mediaToStringArray from '../../util/mediaToStringArray'; 5 | import stripViewportQueries from '../../util/stripViewportQueries'; 6 | import mediaMatchesViewport from '../../util/mediaMatchesViewport'; 7 | import computeSizesAttribute from '../../util/computeSizesAttribute'; 8 | 9 | const threshold = 0.05; 10 | const thresholdPx = 15; 11 | 12 | export default function(image, mediaQueries) { 13 | 14 | const errorItems = []; 15 | 16 | Object.keys(image.dimensions).forEach(viewport => { 17 | 18 | let imageWidth = image.dimensions[viewport]; 19 | const sourceMatched = {}; 20 | 21 | if (!imageWidth) { 22 | return; 23 | } 24 | 25 | allSources(image).forEach((item, itemIndex) => { 26 | 27 | const categories = mediaToStringArray( 28 | stripViewportQueries(item.media) 29 | ).map( 30 | media => (item.type || 'image/*') + '|' + media 31 | ); 32 | 33 | if (categories.reduce((result, category) => result || sourceMatched[category], false)) { 34 | return; 35 | } 36 | 37 | if (item.media && !mediaMatchesViewport(item.media, viewport)) { 38 | return; 39 | } 40 | categories.forEach(category => { 41 | sourceMatched[category] = true; 42 | }); 43 | 44 | if (!item.sizes) { 45 | return; 46 | } 47 | 48 | let sizeMatched = false; 49 | item.sizes.forEach(({size, media}, index) => { 50 | 51 | // If the first item is set to auto, ignore it 52 | if (index === 0 && !media && size === 'auto') { 53 | return; 54 | } 55 | 56 | if (sizeMatched) { 57 | return; 58 | } 59 | 60 | if (media && !mediaMatchesViewport(media, viewport)) { 61 | return; 62 | } 63 | sizeMatched = true; 64 | 65 | let targetWidth = computeLength(size, viewport); 66 | if ( 67 | imageWidth < targetWidth - (targetWidth * threshold) - thresholdPx 68 | || imageWidth > targetWidth + (targetWidth * threshold) + thresholdPx 69 | ) { 70 | errorItems[itemIndex] = errorItems[itemIndex] || {}; 71 | errorItems[itemIndex][viewport] = { 72 | viewport, 73 | targetWidth, 74 | imageWidth, 75 | size, 76 | }; 77 | } 78 | 79 | }); 80 | 81 | }); 82 | 83 | }); 84 | 85 | allSources(image).forEach((item, itemIndex) => { 86 | 87 | if (!errorItems[itemIndex]) { 88 | return; 89 | } 90 | 91 | const firstItem = errorItems[itemIndex][ 92 | [ 93 | '1280x720', '1440x810', '1000x563', '320x427', '480x270', '1920x1080', 94 | Object.keys(errorItems[itemIndex])[0], 95 | ].filter( 96 | viewport => errorItems[itemIndex][viewport] 97 | )[0] 98 | ]; 99 | 100 | let viewportRanges = []; 101 | let lastViewWidth = 0; 102 | 103 | Object.keys(errorItems[itemIndex]).forEach(viewport => { 104 | let viewWidth = viewport.split('x')[0]; 105 | if (Math.abs(lastViewWidth - viewWidth) > 20) { 106 | viewportRanges.push([viewport, viewport]); 107 | } 108 | viewportRanges[viewportRanges.length - 1][1] = viewport; 109 | lastViewWidth = viewWidth; 110 | }); 111 | 112 | for (let i = 0; i < viewportRanges.length; i++) { 113 | for (let j = i + 1; j < viewportRanges.length; j++) { 114 | if ( 115 | viewportRanges[i] 116 | && viewportRanges[j] 117 | && viewportRanges[i][0].split('x')[0] === viewportRanges[j][0].split('x')[0] 118 | && viewportRanges[i][1].split('x')[0] === viewportRanges[j][1].split('x')[0] 119 | ) { 120 | viewportRanges[i][1] = viewportRanges[j][1]; 121 | viewportRanges[j] = undefined; 122 | } 123 | } 124 | } 125 | 126 | viewportRanges = viewportRanges.filter(Boolean); 127 | 128 | error(__filename, item, { 129 | sizes: item.sizes.map(({size, media}) => 130 | (media ? (typeof media === 'object' 131 | ? mediaToStringArray(media).join() 132 | : media 133 | ) + ' ' : '') + size).join(', '), 134 | viewport: firstItem.viewport, 135 | imageWidth: firstItem.imageWidth, 136 | targetWidth: firstItem.targetWidth, 137 | difference: Math.round((1 - (firstItem.imageWidth / firstItem.targetWidth)) * -100) + '%', 138 | viewportRanges: viewportRanges.map(range => range[0] === range[1] ? range[0] : range.join('-')).join(', '), 139 | sizesSuggestion: computeSizesAttribute(image.dimensions, mediaQueries.bySize), 140 | }); 141 | 142 | }); 143 | 144 | } 145 | -------------------------------------------------------------------------------- /src/linter/images/wrongSizes.md: -------------------------------------------------------------------------------- 1 | # The `sizes` attribute has to match the width of the image 2 | 3 | The `sizes` attribute is a hint for browsers which should tell them how large the image will be displayed. If it doesn’t match the real size, browsers cannot select the correct image source. 4 | 5 | ## Good 6 | 7 | ```html 8 | 14 | 20 | 21 | 26 | 32 | 33 | ``` 34 | 35 | ## Bad 36 | 37 | ```html 38 | 44 | 45 | 50 | 56 | 57 | ``` 58 | 59 | ## Error template 60 | 61 | The size of the image doesn’t match the `sizes` attribute `{{sizes}}`. At a viewport of {{viewport}} the image was {{imageWidth}} pixels wide instead of the specified {{targetWidth}} ({{difference}} difference). The affected viewports are {{viewportRanges}}. 62 | 63 | Try using `sizes="{{sizesSuggestion}}"` instead. 64 | -------------------------------------------------------------------------------- /src/linter/index.js: -------------------------------------------------------------------------------- 1 | import prepareMediaQueries from './prepareMediaQueries'; 2 | import descriptors from './descriptors'; 3 | import fallbacks from './fallbacks'; 4 | import images from './images'; 5 | import markup from './markup'; 6 | 7 | export default function(data) { 8 | 9 | prepareMediaQueries(data); 10 | 11 | data.data.forEach(image => { 12 | descriptors(image, data); 13 | fallbacks(image); 14 | images(image, data); 15 | markup(image); 16 | }); 17 | 18 | return data; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/linter/markup/duplicateImg.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(image) { 4 | let imgFound; 5 | if (image.markup.tag === 'picture') { 6 | image.markup.children.forEach(child => { 7 | if (child.tag === 'img') { 8 | if (imgFound) { 9 | error(__filename, image.data); 10 | } 11 | imgFound = true; 12 | } 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/linter/markup/duplicateImg.md: -------------------------------------------------------------------------------- 1 | # Multiple `` elements are not allowed 2 | 3 | Only one `` element is allowed inside of ``. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | ``` 12 | 13 | ## Bad 14 | 15 | ```html 16 | 17 | 18 | 19 | 20 | ``` 21 | -------------------------------------------------------------------------------- /src/linter/markup/extra.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | // Valid child elements for `` according to 4 | // https://html.spec.whatwg.org/multipage/embedded-content.html#the-picture-element 5 | const validChildren = [ 6 | 'source', 7 | 'img', 8 | 'script', 9 | 'template', 10 | ]; 11 | 12 | export default function(image) { 13 | const badTags = []; 14 | if (image.markup.tag === 'picture') { 15 | image.markup.children.forEach(child => { 16 | if (validChildren.indexOf(child.tag) === -1 && badTags.indexOf(child.tag) === -1) { 17 | badTags.push(child.tag); 18 | } 19 | }); 20 | } 21 | if (badTags.length) { 22 | error(__filename, image.data, { 23 | tags: badTags.join(', '), 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/linter/markup/extra.md: -------------------------------------------------------------------------------- 1 | # Only `` and `` tags are allowed inside of `` 2 | 3 | You should not use `` as a wrapper to put additional elements inside it. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | ``` 12 | 13 | ## Bad 14 | 15 | ```html 16 | 17 | 18 | Image Caption 19 | 20 | ``` 21 | 22 | ## Error template 23 | 24 | Element `{{tags}}` was found. 25 | -------------------------------------------------------------------------------- /src/linter/markup/index.js: -------------------------------------------------------------------------------- 1 | import duplicateImg from './duplicateImg'; 2 | import extra from './extra'; 3 | import missingImg from './missingImg'; 4 | import sourceSrc from './sourceSrc'; 5 | import wrongOrder from './wrongOrder'; 6 | 7 | export default function(image) { 8 | duplicateImg(image); 9 | extra(image); 10 | missingImg(image); 11 | sourceSrc(image); 12 | wrongOrder(image); 13 | } 14 | -------------------------------------------------------------------------------- /src/linter/markup/missingImg.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(image) { 4 | if (!image.data.img) { 5 | error(__filename, image.data); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/linter/markup/missingImg.md: -------------------------------------------------------------------------------- 1 | # The `` element must not be omitted inside of `` 2 | 3 | The `` element needs an `` element in order to display something. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | ``` 12 | 13 | ## Bad 14 | 15 | ```html 16 | 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /src/linter/markup/sourceSrc.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(image) { 4 | if (image.markup.tag === 'picture') { 5 | image.markup.children.forEach(child => { 6 | if (child.tag === 'source' && child.attributes.filter(attr => attr.name === 'src').length) { 7 | error(__filename, image.data); 8 | } 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/linter/markup/sourceSrc.md: -------------------------------------------------------------------------------- 1 | # The `src` attribute has no effect on a `` element 2 | 3 | The `` element only supports the `srcset` attribute. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | 12 | ``` 13 | 14 | ## Bad 15 | 16 | ```html 17 | 18 | 19 | 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /src/linter/markup/wrongOrder.js: -------------------------------------------------------------------------------- 1 | import error from '../../util/error'; 2 | 3 | export default function(image) { 4 | let imgFound; 5 | if (image.markup.tag === 'picture') { 6 | image.markup.children.forEach(child => { 7 | if (child.tag === 'img') { 8 | imgFound = true; 9 | } 10 | else if (child.tag === 'source' && imgFound) { 11 | error(__filename, image.data); 12 | } 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/linter/markup/wrongOrder.md: -------------------------------------------------------------------------------- 1 | # The `` element must not appear after an `` element 2 | 3 | The `` element has to be the last element inside of ``. 4 | 5 | ## Good 6 | 7 | ```html 8 | 9 | 10 | 11 | 12 | ``` 13 | 14 | ## Bad 15 | 16 | ```html 17 | 18 | 19 | 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /src/linter/prepareMediaQueries.js: -------------------------------------------------------------------------------- 1 | import parseMedia from '../util/parseMedia'; 2 | import computeLength from '../util/computeLength'; 3 | 4 | export default function prepareMediaQueries(data) { 5 | 6 | const queriesBySize = {}; 7 | 8 | data.mediaQueries.forEach(queryString => { 9 | const parsedQueries = parseMedia(queryString); 10 | if (!Array.isArray(parsedQueries)) { 11 | return; 12 | } 13 | parsedQueries.forEach(query => { 14 | (query.expressions || []).forEach(({feature, modifier, value}) => { 15 | let size = computeLength(value); 16 | if (feature !== 'width' || !size || (modifier !== 'min' && modifier !== 'max')) { 17 | return; 18 | } 19 | if (modifier === 'max') { 20 | size++; 21 | } 22 | queriesBySize[size] = {feature, modifier, value}; 23 | }); 24 | }); 25 | }); 26 | 27 | data.mediaQueries = { 28 | bySize: queriesBySize, 29 | }; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/reporter.js: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import prism from 'prismjs'; 3 | import getDocs from './util/getDocs'; 4 | import allSources from './util/allSources'; 5 | import splitCommaSeparatedListOfComponentValues from './util/splitCommaSeparatedListOfComponentValues'; 6 | 7 | export default function (data) { 8 | 9 | let report = document.createElement('div'); 10 | report.className = 'report'; 11 | 12 | let headline = document.createElement('h1'); 13 | headline.textContent = 'Responsive Images Report for '; 14 | report.appendChild(headline); 15 | 16 | let link = document.createElement('a'); 17 | link.href = link.textContent = data.href; 18 | link.target = '_top'; 19 | headline.appendChild(link); 20 | 21 | let viewSettings = document.createElement('input'); 22 | viewSettings.type = 'checkbox'; 23 | viewSettings.id = 'view-settings'; 24 | report.appendChild(viewSettings); 25 | 26 | let viewSettingsLabel = document.createElement('label'); 27 | viewSettingsLabel.textContent = 'Only show failed checks'; 28 | viewSettingsLabel.setAttribute('for', 'view-settings'); 29 | report.appendChild(viewSettingsLabel); 30 | 31 | data.data.map((image, index) => reportImage(image, index)).forEach(imageReport => { 32 | report.appendChild(imageReport) 33 | }); 34 | 35 | let reportInfo = document.createElement('p'); 36 | reportInfo.textContent = report.querySelectorAll('.report-item.-passed').length + ' out of ' + report.querySelectorAll('.report-item').length + ' images passed all checks.'; 37 | report.insertBefore(reportInfo, viewSettings); 38 | 39 | 40 | if (!data.data.length) { 41 | let text = document.createElement('p'); 42 | text.textContent = 'No images were found on this page.'; 43 | report.appendChild(text); 44 | } 45 | 46 | return report; 47 | 48 | } 49 | 50 | function reportImage(image, index) { 51 | 52 | let report = document.createElement('div'); 53 | report.className = 'report-item'; 54 | 55 | let header = document.createElement('header'); 56 | header.className = 'report-item-header'; 57 | 58 | let img = document.createElement('img'); 59 | img.src = image.data.img 60 | && image.images[image.data.img.src] 61 | && image.images[image.data.img.src].url 62 | || allSources(image).reverse().reduce( 63 | (result, source) => result || source.srcset.reduce( 64 | (url, srcset) => url || ( 65 | image.images[srcset.src] 66 | && image.images[srcset.src].url 67 | ), 68 | false 69 | ), 70 | false 71 | ); 72 | header.appendChild(img); 73 | 74 | let headline = document.createElement('h2'); 75 | headline.textContent = 'Image #' + (index + 1); 76 | header.appendChild(headline); 77 | 78 | report.appendChild(header); 79 | 80 | let errors = buildErrors(image.data, image.images); 81 | 82 | if (errors.length) { 83 | 84 | errors.forEach(error => report.appendChild(error)); 85 | 86 | let markup = document.createElement('pre'); 87 | let html = '' + prism.highlight( 88 | buildMarkup(image.markup), 89 | prism.languages.html, 90 | 'html' 91 | ) + ''; 92 | 93 | Object.keys(image.images).sort( 94 | (a, b) => b.length - a.length 95 | ).forEach(src => { 96 | html = html.replace(new RegExp( 97 | '([>,\\s])(' 98 | + src.replace(/&/g, '&').replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&') 99 | + ')([<,\\s])' 100 | , 'g'), '$1$2$3'); 101 | }); 102 | 103 | markup.innerHTML = html; 104 | 105 | report.appendChild(markup); 106 | 107 | } 108 | else { 109 | let info = document.createElement('p'); 110 | info.textContent = 'All checks passed.'; 111 | report.appendChild(info); 112 | report.className += ' -passed'; 113 | } 114 | 115 | return report; 116 | 117 | } 118 | 119 | function buildMarkup(markup, indentation = '', maxlength = 95) { 120 | 121 | let html = indentation + '<' + markup.tag; 122 | 123 | let attributes = (markup.attributes || []) 124 | .map(({name, value}) => { 125 | if ( 126 | (name === 'srcset' || name === 'sizes') 127 | && value.indexOf(',') !== -1 128 | && name.length + value.length + indentation.length * 4 + 7 > maxlength 129 | ) { 130 | if (name === 'sizes') { 131 | value = '\n' + indentation + '\t\t' + splitCommaSeparatedListOfComponentValues(value.trim()).join(',\n' + indentation + '\t\t') + '\n' + indentation + '\t'; 132 | } else { 133 | value = '\n' + indentation + '\t\t' + value.trim().replace(/,\s+/g, ',\n' + indentation + '\t\t') + '\n' + indentation + '\t'; 134 | } 135 | } 136 | return name + '="' + value + '"'; 137 | }); 138 | if (attributes.length) { 139 | if (attributes.join(' ').length + markup.tag.length + indentation.length * 4 + 3 > maxlength) { 140 | html += '\n' + indentation + '\t' + attributes.join('\n' + indentation + '\t') + '\n' + indentation; 141 | } 142 | else { 143 | html += ' ' + attributes.join(' '); 144 | } 145 | } 146 | 147 | html += '>'; 148 | 149 | let contents = (markup.children || []) 150 | .map(child => buildMarkup(child, indentation + '\t', maxlength)) 151 | .join('\n'); 152 | 153 | if (contents) { 154 | html += '\n' + contents + '\n' + indentation; 155 | } 156 | 157 | if (markup.tag !== 'img' && markup.tag !== 'source') { 158 | html += ''; 159 | } 160 | 161 | return html; 162 | 163 | } 164 | 165 | function buildErrors(data, images) { 166 | let errors = {}; 167 | 168 | if (data.errors) { 169 | data.errors.forEach(error => { 170 | errors[error.key] = errors[error.key] || []; 171 | errors[error.key].push(error); 172 | }); 173 | } 174 | 175 | data.sources.forEach(source => { 176 | if (source.errors) { 177 | source.errors.forEach(error => { 178 | errors[error.key] = errors[error.key] || []; 179 | errors[error.key].push(error); 180 | }); 181 | } 182 | }); 183 | 184 | if (data.img && data.img.errors) { 185 | data.img.errors.forEach(error => { 186 | errors[error.key] = errors[error.key] || []; 187 | errors[error.key].push(error); 188 | }); 189 | } 190 | 191 | return Object.keys(errors).map(key => buildError(key, errors[key], images)); 192 | } 193 | 194 | function buildError(key, errors, images) { 195 | 196 | let element = document.createElement('div'); 197 | 198 | let headline = document.createElement('h3'); 199 | headline.innerHTML = marked(errors[0].msg).replace(/<\/?p>/gi, ''); 200 | element.appendChild(headline); 201 | 202 | let message = document.createElement('div'); 203 | errors.forEach(({key, data}) => message.appendChild(buildErrorMessage(key, data, images))); 204 | element.appendChild(message); 205 | 206 | let text = document.createElement('div'); 207 | text.innerHTML = marked(getDocs(key, 'text')); 208 | element.appendChild(text); 209 | 210 | return element; 211 | 212 | } 213 | 214 | function buildErrorMessage(key, data, images) { 215 | 216 | let element = document.createElement('div'); 217 | 218 | let message = getDocs(key, 'Error template'); 219 | 220 | const urls = []; 221 | 222 | Object.keys(data).forEach(key => { 223 | if (images[data[key]]) { 224 | data[key + 'Url'] = images[data[key]].url; 225 | data[key + 'Type'] = images[data[key]].type; 226 | data[key + 'Size'] = images[data[key]].size.width + 'x' + images[data[key]].size.height; 227 | data[key + 'Width'] = images[data[key]].size.width; 228 | data[key + 'Height'] = images[data[key]].size.height; 229 | urls.push(data[key]); 230 | } 231 | }); 232 | 233 | const shortUrls = shortenUrls(urls); 234 | 235 | urls.forEach((url, i) => { 236 | Object.keys(data).forEach(key => { 237 | if (images[data[key]] && data[key] === url && key.substr(-3) !== 'Url') { 238 | data[key] = shortUrls[i]; 239 | } 240 | }); 241 | }); 242 | 243 | Object.keys(data).forEach(key => { 244 | message = message.split('{{' + key + '}}').join( 245 | typeof data[key] === 'number' 246 | ? data[key] 247 | : (data[key] || '\u200B') 248 | ); 249 | }); 250 | 251 | element.innerHTML = marked(message); 252 | 253 | return element; 254 | 255 | } 256 | 257 | function shortenUrl(url, maxLength) { 258 | 259 | if (!url || url.length <= maxLength) { 260 | return url; 261 | } 262 | 263 | url = url.replace(/^https?:\/\/[^/]+\//gi, ''); 264 | let query = ''; 265 | 266 | let prefix = ''; 267 | let suffix = ''; 268 | 269 | if (url.indexOf('?') !== -1) { 270 | query = url.substr(url.indexOf('?')); 271 | url = url.substr(0, url.indexOf('?')); 272 | } 273 | 274 | while (url.length + query.length >= maxLength && url.indexOf('/') !== -1) { 275 | url = url.substr(url.indexOf('/') + 1); 276 | prefix = '…/'; 277 | } 278 | 279 | while (url.length + query.length >= maxLength && query.lastIndexOf('&') !== -1) { 280 | query = query.substr(0, query.lastIndexOf('&')); 281 | suffix = '…'; 282 | } 283 | 284 | return prefix + url + query + suffix; 285 | } 286 | 287 | function shortenUrls(urls) { 288 | 289 | const maxLength = 32; 290 | 291 | if (!urls) { 292 | return urls; 293 | } 294 | 295 | if (urls.length < 2) { 296 | return urls.map(url => shortenUrl(url, maxLength)); 297 | } 298 | 299 | let longestLength = urls.map(url => url.length).sort((a, b) => b - a)[0]; 300 | if (longestLength <= maxLength) { 301 | return urls; 302 | } 303 | 304 | let prefix = ''; 305 | let done = false; 306 | while (!done) { 307 | prefix += urls[0][prefix.length]; 308 | for (let i = 0; i < urls.length; i++) { 309 | if (urls[i].substr(0, prefix.length) !== prefix) { 310 | prefix = prefix.substr(0, prefix.length - 1); 311 | done = true; 312 | break; 313 | } 314 | } 315 | } 316 | prefix = prefix.replace(/[^/]*$/gi, ''); 317 | 318 | urls = urls.map(url => url.substr(prefix.length)); 319 | 320 | longestLength -= prefix.length; 321 | if (longestLength <= maxLength) { 322 | return urls; 323 | } 324 | 325 | let suffix = ''; 326 | done = false; 327 | while (!done) { 328 | suffix = urls[0].substr(urls[0].length - suffix.length - 1, 1) + suffix; 329 | for (let i = 0; i < urls.length; i++) { 330 | if (urls[i].substr(urls[i].length - suffix.length) !== suffix) { 331 | suffix = suffix.substr(1); 332 | done = true; 333 | break; 334 | } 335 | } 336 | } 337 | suffix = suffix.replace(/^[^?&]*/gi, ''); 338 | 339 | urls = urls.map(url => url.substr(0, url.length - suffix.length)); 340 | 341 | longestLength -= suffix.length; 342 | if (longestLength <= maxLength) { 343 | return urls; 344 | } 345 | 346 | const queryCounts = {}; 347 | urls.forEach(url => { 348 | if (url.indexOf('?') === -1) { 349 | return; 350 | } 351 | url.substr(url.indexOf('?') + 1).split('&') 352 | .filter((val, index, arr) => arr.indexOf(val) === index) 353 | .forEach(queryPart => { 354 | queryCounts[queryPart] = (queryCounts[queryPart] || 0) + 1; 355 | }); 356 | }); 357 | 358 | Object.keys(queryCounts).forEach(queryPart => { 359 | if (queryCounts[queryPart] !== urls.length) { 360 | return; 361 | } 362 | urls = urls.map(url => url.substr(0, url.indexOf('?') + 1) 363 | + url.substr(url.indexOf('?') + 1).split('&') 364 | .map(val => val === queryPart ? '…' : val) 365 | .join('&') 366 | ); 367 | }); 368 | 369 | urls = urls.map(url => 370 | url.replace(/[?&]…(?:&…)*(?:&|$)/gi, '…') 371 | ); 372 | 373 | return urls.map(url => 374 | (prefix.length ? '…/' : '') 375 | + url 376 | + (suffix.length ? suffix[0] + '…' : '') 377 | ); 378 | } 379 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('message', function(event) { 2 | localStorage.respImageLintData = event.data; 3 | event.source.postMessage('respImageLintStoreDone', '*'); 4 | }); 5 | 6 | if (window.parent && window.parent !== window) { 7 | window.parent.postMessage('respImageLintStoreReady', '*'); 8 | } 9 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import collector from './collector/index'; 2 | import linter from './linter/index'; 3 | 4 | collector(document, true) 5 | .then(data => { 6 | data.data.forEach(image => { 7 | for ( 8 | var node = image.dom.img || image.dom.sources[0]; 9 | node; 10 | node = node.parentNode 11 | ) { 12 | if (node.getAttribute('data-test-key') && node.getAttribute('data-test-type')) { 13 | image.test = { 14 | key: node.getAttribute('data-test-key'), 15 | type: node.getAttribute('data-test-type'), 16 | }; 17 | break; 18 | } 19 | } 20 | delete image.dom; 21 | }); 22 | return data; 23 | }) 24 | .then(linter) 25 | .then(data => { 26 | window.callPhantom(data.data); 27 | }); 28 | -------------------------------------------------------------------------------- /src/util/allMarkupSources.js: -------------------------------------------------------------------------------- 1 | export default function allMarkupSources(image) { 2 | let sources = readSources(image.markup); 3 | let imgs = readImgs(image.markup); 4 | 5 | if (imgs.length) { 6 | sources.push(imgs[0]); 7 | } 8 | 9 | return sources; 10 | } 11 | 12 | function readSources(element) { 13 | if (element.tag === 'source') { 14 | return [element]; 15 | } 16 | 17 | return element.children.flatMap(readSources); 18 | } 19 | 20 | function readImgs(element) { 21 | if (element.tag === 'img') { 22 | return [element]; 23 | } 24 | 25 | return element.children.flatMap(readImgs); 26 | } 27 | -------------------------------------------------------------------------------- /src/util/allSources.js: -------------------------------------------------------------------------------- 1 | export default function allSources(image) { 2 | let sources = image.data.sources.slice(0); 3 | if (image.data.img) { 4 | sources.push(image.data.img); 5 | } 6 | return sources; 7 | } 8 | -------------------------------------------------------------------------------- /src/util/computeLength.js: -------------------------------------------------------------------------------- 1 | export default function(length, viewport) { 2 | 3 | if (!length) { 4 | return 0; 5 | } 6 | 7 | if (typeof viewport === 'string') { 8 | viewport = viewport.split('x', 2).map(parseFloat); 9 | } 10 | 11 | if (!viewport || !viewport[0] || !viewport[1]) { 12 | viewport = [0, 0]; 13 | } 14 | 15 | length = length.replace( 16 | /\d*\.?\d+[sld]?v(w|h|i|b|min|max)/gi, 17 | (match, unit) => parseFloat(match) * ( 18 | unit === 'w' || unit === 'i' ? viewport[0] : 19 | unit === 'h' || unit === 'b' ? viewport[1] : 20 | unit === 'min' ? Math.min(viewport[0], viewport[1]) : 21 | unit === 'max' ? Math.max(viewport[0], viewport[1]) : 22 | 0 23 | ) / 100 + 'px' 24 | ); 25 | 26 | if (length.match(/^\d*\.?\d+px$/)) { 27 | return Math.round(parseFloat(length)); 28 | } 29 | 30 | if (length.match(/^\d*\.?\d+r?em$/)) { 31 | return Math.round(parseFloat(length) * 16); 32 | } 33 | 34 | const wrap = document.createElement('div'); 35 | wrap.style.width = 0; 36 | wrap.style.fontSize = '16px'; 37 | 38 | const element = document.createElement('div'); 39 | element.style.width = length; 40 | 41 | wrap.appendChild(element); 42 | document.body.appendChild(wrap); 43 | 44 | const result = element.offsetWidth; 45 | 46 | document.body.removeChild(wrap); 47 | 48 | return result; 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/util/computeSizesAttribute.js: -------------------------------------------------------------------------------- 1 | const threshold = 0.05; 2 | 3 | export default function computeSizesAttribute(allDimensions, queriesBySize) { 4 | 5 | const byViewport = {}; 6 | 7 | Object.keys(allDimensions).forEach(viewport => { 8 | if (!byViewport[viewport.split('x')[0]] && allDimensions[viewport]) { 9 | byViewport[viewport.split('x')[0]] = allDimensions[viewport]; 10 | } 11 | }); 12 | 13 | const ranges = []; 14 | let currentRange; 15 | 16 | Object.keys(byViewport).map(Number).sort((a, b) => a - b).forEach(viewport => { 17 | 18 | const width = byViewport[viewport]; 19 | 20 | // First item 21 | if (!currentRange) { 22 | currentRange = { 23 | fixed: width, 24 | variable: 0, 25 | viewports: [viewport], 26 | }; 27 | return; 28 | } 29 | 30 | const firstViewport = currentRange.viewports[0]; 31 | const firstWidth = byViewport[firstViewport]; 32 | const tryRange = { 33 | variable: (width - firstWidth) / (viewport - firstViewport), 34 | }; 35 | tryRange.fixed = width - (viewport * tryRange.variable); 36 | 37 | // Extend current range if possible 38 | if (!currentRange.viewports.filter( 39 | viewport => !isInThreshold(calculate(viewport, tryRange), byViewport[viewport]) 40 | ).length) { 41 | currentRange.variable = tryRange.variable; 42 | currentRange.fixed = tryRange.fixed; 43 | currentRange.viewports.push(viewport); 44 | return; 45 | } 46 | 47 | // Start a new range 48 | ranges.push(currentRange); 49 | currentRange = { 50 | fixed: width, 51 | variable: 0, 52 | viewports: [viewport], 53 | }; 54 | 55 | }); 56 | 57 | // Add last range 58 | if (currentRange) { 59 | ranges.push(currentRange); 60 | } 61 | 62 | // Start with the biggest viewport 63 | ranges.reverse(); 64 | 65 | return ranges.map(({fixed, variable, viewports}, index) => { 66 | let result = ''; 67 | const vw = (Math.round(variable * 10000) / 100) + 'vw'; 68 | const px = Math.round(Math.abs(fixed)) + 'px'; 69 | if (ranges[index + 1]) { 70 | result += '(min-width: ' + viewports[0] + 'px) '; 71 | } 72 | if (Math.abs(fixed) < viewports[0] * Math.abs(variable) * threshold) { 73 | result += vw; 74 | } 75 | else if (viewports[0] * Math.abs(variable) < Math.abs(fixed) * threshold) { 76 | result += px; 77 | } 78 | else { 79 | result += 'calc(' + vw + ' ' + (fixed < 0 ? '-' : '+') + ' ' + px + ')'; 80 | } 81 | return result; 82 | }).join(', '); 83 | 84 | } 85 | 86 | function calculate(viewport, {variable, fixed}) { 87 | return viewport * variable + fixed; 88 | } 89 | 90 | function isInThreshold(value, width) { 91 | return value > width * (1 - threshold) && value < width * (1 + threshold); 92 | } 93 | -------------------------------------------------------------------------------- /src/util/computeSrcsetWidths.js: -------------------------------------------------------------------------------- 1 | import roundWidth from './roundWidth'; 2 | 3 | export default function computeSrcsetWidths(dimensions, ratio, viewportsCount, existingWidths, { 4 | recommendedMinWidth = 0, 5 | recommendedMaxWidth = 16384, 6 | megapixelThreshold = 0.25, 7 | megapixelGap = 0.5, 8 | commonDevices = [], 9 | } = {}) { 10 | const maxWidth = Math.min(recommendedMaxWidth, Math.round(Math.max(...Object.values(dimensions)))); 11 | const minWidth = Math.min(maxWidth, Math.max(recommendedMinWidth, Math.round(Math.min(...Object.values(dimensions).filter(width => width > 0))))); 12 | const fixedWidths = []; 13 | const widthCounts = {}; 14 | 15 | Object.values(dimensions).forEach(width => { 16 | widthCounts[width] = widthCounts[width] || 0; 17 | widthCounts[width]++; 18 | }); 19 | 20 | // If the image size is fixed (not fluid) for some viewports, these exact dimensions (including @2x and @3x versions) should be used 21 | Object.keys(widthCounts).forEach(width => { 22 | width = parseInt(width); 23 | if (widthCounts[width] > viewportsCount / 8) { 24 | [ 25 | Math.min(recommendedMaxWidth, width), 26 | Math.min(recommendedMaxWidth, width * 2), 27 | Math.min(recommendedMaxWidth, width * 3), 28 | ].forEach(width => { 29 | if (!fixedWidths.includes(width)) { 30 | fixedWidths.push(width); 31 | } 32 | }); 33 | } 34 | }); 35 | 36 | commonDevices.forEach(device => { 37 | let width = getDimensionFromDeviceWidth(device.width); 38 | if (width) { 39 | fixedWidths.push(Math.ceil(width * device.dpr)); 40 | } 41 | }); 42 | 43 | fixedWidths.push(...existingWidths.filter(width => width >= minWidth && width <= maxWidth * 2)); 44 | 45 | fixedWidths.sort((a, b) => a < b ? -1 : 1); 46 | 47 | if (!fixedWidths[0] || getMegapixels(minWidth) < getMegapixels(fixedWidths[0]) - megapixelThreshold) { 48 | fixedWidths.unshift(roundWidth(minWidth)); 49 | } 50 | 51 | if (getMegapixels(maxWidth) > getMegapixels(fixedWidths[fixedWidths.length - 1]) + megapixelThreshold) { 52 | fixedWidths.push(roundWidth(maxWidth)); 53 | } 54 | 55 | if (getMegapixels(Math.min(recommendedMaxWidth, maxWidth * 2)) > getMegapixels(fixedWidths[fixedWidths.length - 1]) + megapixelThreshold) { 56 | fixedWidths.push(Math.min(recommendedMaxWidth, roundWidth(maxWidth * 2))); 57 | } 58 | 59 | const allWidths = []; 60 | 61 | fixedWidths.reverse().forEach((width, index) => { 62 | const previousWidth = allWidths[allWidths.length - 1]; 63 | const gap = previousWidth && getMegapixels(previousWidth) - getMegapixels(width); 64 | if (gap < megapixelThreshold) { 65 | return; 66 | } 67 | else if (gap > megapixelGap) { 68 | const gapSize = gap / Math.ceil(gap / megapixelGap); 69 | let nextWidth = previousWidth; 70 | while (width + 10 < (nextWidth = getWidth(getMegapixels(nextWidth) - gapSize))) { 71 | allWidths.push(roundWidth(nextWidth)); 72 | } 73 | } 74 | allWidths.push(width); 75 | }); 76 | 77 | return allWidths.reverse(); 78 | 79 | function getMegapixels(width) { 80 | return width * width * ratio / 1000000; 81 | } 82 | 83 | function getWidth(megapixels) { 84 | return Math.round(Math.sqrt(megapixels * 1000000 / ratio)); 85 | } 86 | 87 | function getDimensionFromDeviceWidth(deviceWidth) { 88 | let nearestViewportAbove; 89 | let nearestViewportBelow; 90 | 91 | Object.keys(dimensions).forEach(viewport => { 92 | const width = Number(viewport.split('x')[0]); 93 | if (width >= deviceWidth && (!nearestViewportAbove || width < Number(nearestViewportAbove.split('x')[0]))) { 94 | nearestViewportAbove = viewport; 95 | } 96 | if (width <= deviceWidth && (!nearestViewportBelow || width > Number(nearestViewportBelow.split('x')[0]))) { 97 | nearestViewportBelow = viewport; 98 | } 99 | }); 100 | 101 | if (nearestViewportAbove === nearestViewportBelow) { 102 | return dimensions[nearestViewportAbove] || 0; 103 | } 104 | 105 | if (!nearestViewportAbove || !nearestViewportBelow) { 106 | return 0; 107 | } 108 | 109 | const aboveWidth = Number(nearestViewportAbove.split('x')[0]); 110 | const belowWidth = Number(nearestViewportBelow.split('x')[0]); 111 | 112 | return ( 113 | (dimensions[nearestViewportAbove] / (aboveWidth - belowWidth) * (deviceWidth - belowWidth)) 114 | + (dimensions[nearestViewportBelow] / (aboveWidth - belowWidth) * (aboveWidth - deviceWidth)) 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/util/error.js: -------------------------------------------------------------------------------- 1 | import {sep as pathSeparator} from 'path'; 2 | import getDocs from './getDocs'; 3 | 4 | export default function error(filename, item, data = {}) { 5 | let key = filename.substr(1, filename.length - 4).split(pathSeparator); 6 | key.shift(); 7 | key.shift(); 8 | key = key.join('.'); 9 | let doc = getDocs(key); 10 | let msg = doc.split('\n')[0].substr(2); 11 | item.errors = item.errors || []; 12 | item.errors.push({key, msg, data}); 13 | } 14 | -------------------------------------------------------------------------------- /src/util/getDocs.js: -------------------------------------------------------------------------------- 1 | /*global require*/ 2 | const docs = JSON.parse(require('fs').readFileSync(__dirname + '/../../tmp/docs.json', 'utf-8')); 3 | 4 | export default function getDocs(key, section) { 5 | 6 | let doc = docs[key]; 7 | 8 | if (!section) { 9 | return doc; 10 | } 11 | 12 | if (section === 'title') { 13 | return doc.split('\n')[0].substr(2); 14 | } 15 | 16 | if (section === 'text') { 17 | doc = doc.split(/^#[^\n]*/); 18 | } 19 | else { 20 | doc = doc.split('\n\n## ' + section + '\n\n'); 21 | } 22 | 23 | if (doc.length < 2) { 24 | return ''; 25 | } 26 | 27 | return doc[1].split('\n\n##')[0].trim(); 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/util/hashDistance.js: -------------------------------------------------------------------------------- 1 | export default function hashDistance(hashA, hashB) { 2 | 3 | if (hashA === hashB) { 4 | return 0; 5 | } 6 | 7 | let dist = 0; 8 | for (let i = 0; i < hashA.length; i++) { 9 | dist += Math.abs(parseInt(hashA[i], 16) - parseInt(hashB[i], 16)); 10 | } 11 | 12 | return dist / hashA.length / 15; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/util/jpegQuality.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the estimated JPEG quality of an image between 0 and 100 3 | * 4 | * @param {ArrayBuffer} buffer 5 | * @return number 6 | */ 7 | export default function jpegQuality(buffer) { 8 | 9 | const data = new Uint8Array(buffer); 10 | const tables = getQuantizationTables(data); 11 | 12 | if (!tables.length) { 13 | throw new Error('Missing quantization tables'); 14 | } 15 | 16 | let hash, hashMap; 17 | let sum, sumMap; 18 | 19 | if (tables.length < 2) { 20 | sum = tables[0].values.reduce((a, b) => a + b); 21 | sumMap = sumMaps[0]; 22 | hash = tables[0].values[2] + tables[0].values[53]; 23 | hashMap = hashMaps[0]; 24 | } 25 | else { 26 | sum = tables[0].values.reduce((a, b) => a + b) + tables[1].values.reduce((a, b) => a + b); 27 | sumMap = sumMaps[1]; 28 | hash = tables[0].values[2] + tables[0].values[53] + tables[1].values[0] + tables[1].values[63]; 29 | hashMap = hashMaps[1]; 30 | } 31 | 32 | return Math.min( 33 | getInterpolatedFromMap(hash, hashMap), 34 | getInterpolatedFromMap(sum, sumMap) 35 | ); 36 | 37 | } 38 | 39 | const hashMaps = [ 40 | [ 41 | 510, 505, 422, 380, 355, 338, 326, 318, 311, 305, 42 | 300, 297, 293, 291, 288, 286, 284, 283, 281, 280, 43 | 279, 278, 277, 273, 262, 251, 243, 233, 225, 218, 44 | 211, 205, 198, 193, 186, 181, 177, 172, 168, 164, 45 | 158, 156, 152, 148, 145, 142, 139, 136, 133, 131, 46 | 129, 126, 123, 120, 118, 115, 113, 110, 107, 105, 47 | 102, 100, 97, 94, 92, 89, 87, 83, 81, 79, 48 | 76, 74, 70, 68, 66, 63, 61, 57, 55, 52, 49 | 50, 48, 44, 42, 39, 37, 34, 31, 29, 26, 50 | 24, 21, 18, 16, 13, 11, 8, 6, 3, 2, 51 | 0, 52 | ], 53 | [ 54 | 1020, 1015, 932, 848, 780, 735, 702, 679, 660, 645, 55 | 632, 623, 613, 607, 600, 594, 589, 585, 581, 571, 56 | 555, 542, 529, 514, 494, 474, 457, 439, 424, 410, 57 | 397, 386, 373, 364, 351, 341, 334, 324, 317, 309, 58 | 299, 294, 287, 279, 274, 267, 262, 257, 251, 247, 59 | 243, 237, 232, 227, 222, 217, 213, 207, 202, 198, 60 | 192, 188, 183, 177, 173, 168, 163, 157, 153, 148, 61 | 143, 139, 132, 128, 125, 119, 115, 108, 104, 99, 62 | 94, 90, 84, 79, 74, 70, 64, 59, 55, 49, 63 | 45, 40, 34, 30, 25, 20, 15, 11, 6, 4, 64 | 0, 65 | ], 66 | ]; 67 | 68 | const sumMaps = [ 69 | [ 70 | 16320, 16315, 15946, 15277, 14655, 14073, 13623, 13230, 12859, 71 | 12560, 12240, 11861, 11456, 11081, 10714, 10360, 10027, 9679, 72 | 9368, 9056, 8680, 8331, 7995, 7668, 7376, 7084, 6823, 73 | 6562, 6345, 6125, 5939, 5756, 5571, 5421, 5240, 5086, 74 | 4976, 4829, 4719, 4616, 4463, 4393, 4280, 4166, 4092, 75 | 3980, 3909, 3835, 3755, 3688, 3621, 3541, 3467, 3396, 76 | 3323, 3247, 3170, 3096, 3021, 2952, 2874, 2804, 2727, 77 | 2657, 2583, 2509, 2437, 2362, 2290, 2211, 2136, 2068, 78 | 1996, 1915, 1858, 1773, 1692, 1620, 1552, 1477, 1398, 79 | 1326, 1251, 1179, 1109, 1031, 961, 884, 814, 736, 80 | 667, 592, 518, 441, 369, 292, 221, 151, 86, 81 | 64, 0, 82 | ], 83 | [ 84 | 32640, 32635, 32266, 31495, 30665, 29804, 29146, 28599, 28104, 85 | 27670, 27225, 26725, 26210, 25716, 25240, 24789, 24373, 23946, 86 | 23572, 22846, 21801, 20842, 19949, 19121, 18386, 17651, 16998, 87 | 16349, 15800, 15247, 14783, 14321, 13859, 13535, 13081, 12702, 88 | 12423, 12056, 11779, 11513, 11135, 10955, 10676, 10392, 10208, 89 | 9928, 9747, 9564, 9369, 9193, 9017, 8822, 8639, 8458, 90 | 8270, 8084, 7896, 7710, 7527, 7347, 7156, 6977, 6788, 91 | 6607, 6422, 6236, 6054, 5867, 5684, 5495, 5305, 5128, 92 | 4945, 4751, 4638, 4442, 4248, 4065, 3888, 3698, 3509, 93 | 3326, 3139, 2957, 2775, 2586, 2405, 2216, 2037, 1846, 94 | 1666, 1483, 1297, 1109, 927, 735, 554, 375, 201, 95 | 128, 0, 96 | ], 97 | ]; 98 | 99 | function getQuantizationTables(data) { 100 | return getHeaders(data, 0xDB).reduce((tables, table) => { 101 | if (table.length % 65) { 102 | throw new Error('Invalid quantization table length ' + table.length); 103 | } 104 | for (let i = 0; i < table.length; i += 65) { 105 | tables.push({ 106 | index: table[i] & 0x0F, 107 | precision: (table[i] & 0xF0) / 16, 108 | values: table.slice(i + 1, i + 65), 109 | }); 110 | } 111 | return tables; 112 | }, []); 113 | } 114 | 115 | function getHeaders(data, type) { 116 | 117 | if (data[0] !== 0xFF || data[1] !== 0xD8 || data[2] !== 0xFF) { 118 | throw new Error('Not a JPEG'); 119 | } 120 | 121 | let current = 2; 122 | 123 | const headers = []; 124 | 125 | while (current < data.length) { 126 | 127 | if (data[current] !== 0xFF) { 128 | throw new Error('Corrupt JPEG'); 129 | } 130 | 131 | const marker = data[current + 1]; 132 | 133 | // Ignore restart markers 134 | if (marker >= 0xD0 && marker <= 0xD7) { 135 | current += 2; 136 | continue; 137 | } 138 | 139 | // Stop at SOS marker 140 | if (marker === 0xDA) { 141 | break; 142 | } 143 | 144 | const size = data[current + 2] * 0x100 + data[current + 3] - 2; 145 | 146 | if (marker === type) { 147 | headers.push(new Uint8Array(data.buffer, current + 4, size)); 148 | } 149 | 150 | // Move the pointer and continue 151 | current += 4 + size; 152 | 153 | } 154 | 155 | return headers; 156 | 157 | } 158 | 159 | function getInterpolatedFromMap(val, map) { 160 | let a, b; 161 | for (var i = 0; i < map.length; i++) { 162 | if (val >= map[i]) { 163 | if (!i) { 164 | return i; 165 | } 166 | return i - ((val - map[i]) / (map[i - 1] - map[i])); 167 | } 168 | } 169 | return i; 170 | } 171 | -------------------------------------------------------------------------------- /src/util/mediaMatchesViewport.js: -------------------------------------------------------------------------------- 1 | import computeLength from './computeLength'; 2 | 3 | /** 4 | * Evaluates media queries and returns false if all of them have a non-matching 5 | * viewport query, otherwise true 6 | * 7 | * @param {array} media 8 | * @param {string} viewport 9 | * @return {boolean} 10 | */ 11 | export default function(media, viewport) { 12 | 13 | if (!media || typeof media === 'string') { 14 | return true; 15 | } 16 | 17 | if (typeof viewport === 'string') { 18 | viewport = viewport.split('x', 2).map(parseFloat); 19 | } 20 | 21 | return !!media.filter(({type, expressions, inverse}) => { 22 | 23 | let matches = true; 24 | 25 | expressions.filter( 26 | ({feature}) => feature === 'width' || feature === 'height' 27 | ).forEach(({feature, modifier, value}) => { 28 | value = computeLength(value, viewport); 29 | let viewSize = viewport[feature === 'width' ? 0 : 1]; 30 | if ( 31 | (modifier === 'min' && viewSize < value) 32 | || (modifier === 'max' && viewSize > value) 33 | || (!modifier && viewSize !== value) 34 | ) { 35 | if (!inverse) { 36 | matches = false; 37 | } 38 | } 39 | else { 40 | if (inverse) { 41 | matches = false; 42 | } 43 | } 44 | }); 45 | 46 | expressions.filter( 47 | ({feature}) => feature === 'orientation' 48 | ).forEach(({value}) => { 49 | if ( 50 | (value === 'portrait' && viewport[0] > viewport[1]) 51 | || (value === 'landscape' && viewport[0] <= viewport[1]) 52 | ) { 53 | if (!inverse) { 54 | matches = false; 55 | } 56 | } 57 | else { 58 | if (inverse) { 59 | matches = false; 60 | } 61 | } 62 | }); 63 | 64 | expressions.filter( 65 | ({feature}) => feature === 'aspect-ratio' 66 | ).forEach(({modifier, value}) => { 67 | value = value.split('/').map(parseFloat); 68 | if (!value[0] || !value[1]) { 69 | return; 70 | } 71 | value = value[0] / value[1]; 72 | let viewRatio = viewport[0] / viewport[1]; 73 | if ( 74 | (modifier === 'min' && viewRatio < value) 75 | || (modifier === 'max' && viewRatio > value) 76 | || (!modifier && viewRatio !== value) 77 | ) { 78 | if (!inverse) { 79 | matches = false; 80 | } 81 | } 82 | else { 83 | if (inverse) { 84 | matches = false; 85 | } 86 | } 87 | }); 88 | 89 | return matches; 90 | 91 | }).length; 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/util/mediaToStringArray.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an array with strings for each media query, `all` is represented as 3 | * an empty string, media conditions are sorted 4 | * 5 | * @param {array} media 6 | * @return {array} 7 | */ 8 | export default function(media) { 9 | if (!media || !media.length) { 10 | return ['']; 11 | } 12 | if (typeof media === 'string') { 13 | return [media]; 14 | } 15 | return media.map(({type, expressions, inverse}) => { 16 | let string = inverse ? 'not ' : ''; 17 | string += (type === 'all' && !inverse) ? '' : type; 18 | if (expressions.length && string) { 19 | string += ' and '; 20 | } 21 | string += expressions.map(({feature, modifier, value}) => 22 | '(' 23 | + (modifier ? modifier + '-' : '') 24 | + feature 25 | + (value ? ': ' + value : '') 26 | + ')' 27 | ).sort().join(' and '); 28 | return string; 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/util/parseMedia.js: -------------------------------------------------------------------------------- 1 | import mqParser from 'css-mq-parser'; 2 | 3 | export default function parseMedia(attribute) { 4 | if (!attribute) { 5 | return undefined; 6 | } 7 | try { 8 | return mqParser(attribute.toLowerCase().replace(/\s+/g, ' ')); 9 | } 10 | catch(e) { 11 | return attribute; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/util/roundWidth.js: -------------------------------------------------------------------------------- 1 | export default function roundWidth(width) { 2 | const powersOfTwo = []; 3 | 4 | for (let i = 2; i <= 1048576; i *= 2) { 5 | powersOfTwo.push(i); 6 | } 7 | 8 | const rounded = width < 100 ? width : width < 500 ? Math.round(width / 5) * 5 : Math.round(width / 10) * 10; 9 | 10 | return [rounded, ...powersOfTwo].sort((a, b) => Math.abs(width - a) - Math.abs(width - b))[0]; 11 | } 12 | -------------------------------------------------------------------------------- /src/util/sameRatio.js: -------------------------------------------------------------------------------- 1 | export default function sameRatio( 2 | {width: widthA, height: heightA}, 3 | {width: widthB, height: heightB}, 4 | threshold = 0.02, 5 | thresholdPx = 2 6 | ) { 7 | 8 | if (!widthA || !heightA || !widthB || !heightB) { 9 | return false; 10 | } 11 | 12 | let aW = Math.round(widthB / heightB * heightA); 13 | let bW = Math.round(widthA / heightA * heightB); 14 | 15 | return (aW > widthA * (1 - threshold) - thresholdPx && aW < widthA * (1 + threshold) + thresholdPx) 16 | || (bW > widthB * (1 - threshold) - thresholdPx && bW < widthB * (1 + threshold) + thresholdPx); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/util/setStyles.js: -------------------------------------------------------------------------------- 1 | export default function setStyles(element, styles, important = true) { 2 | Object.keys(styles).forEach(prop => { 3 | element.style.setProperty(prop, styles[prop], important ? 'important' : ''); 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/util/splitCommaSeparatedListOfComponentValues.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} componentValuesString 3 | * @return {array} 4 | */ 5 | export default function(componentValuesString) { 6 | 7 | const matchToken = /[,()"']/g; 8 | const componentValues = []; 9 | let start = 0 10 | let depth = 0; 11 | 12 | let token; 13 | while (token = matchToken.exec(componentValuesString)) { 14 | if (token[0] === ',' && depth === 0) { 15 | componentValues.push(componentValuesString.substring(start, matchToken.lastIndex - 1).trim()); 16 | start = matchToken.lastIndex; 17 | } 18 | if (token[0] === '(') { 19 | ++depth; 20 | } 21 | if (token[0] === ')' && depth > 0) { 22 | --depth; 23 | } 24 | if (token[0] === '"' || token[0] === '\'') { 25 | // Skip to end of string 26 | matchToken.lastIndex = componentValuesString.indexOf(token[0], matchToken.lastIndex); 27 | if (matchToken.lastIndex === -1) { 28 | break; 29 | } 30 | } 31 | } 32 | 33 | componentValues.push(componentValuesString.substring(start).trim()); 34 | 35 | return componentValues; 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/util/stripViewportQueries.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | 3 | const viewportFeatures = ['width', 'height', 'aspect-ratio', 'orientation']; 4 | 5 | /** 6 | * Strips viewport queries that can be evaluated by mediaMatchesViewport() 7 | * 8 | * @param {array} media [description] 9 | * @return {array} 10 | */ 11 | export default function(media) { 12 | if (!media || typeof media === 'string') { 13 | return media; 14 | } 15 | return cloneDeep(media).map(item => { 16 | item.expressions = item.expressions.filter( 17 | ({feature}) => viewportFeatures.indexOf(feature) === -1 18 | ); 19 | if (!item.expressions.length && item.type === 'all' && item.inverse) { 20 | item.inverse = false; 21 | } 22 | return item; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /styles/_common.sass: -------------------------------------------------------------------------------- 1 | $text-color: black 2 | $link-color: #2D7AB3 3 | $link-visited-color: #882DB3 4 | $background-color: white 5 | $border-color: #CCCCCC 6 | $red: crimson 7 | $green: seagreen 8 | 9 | html 10 | box-sizing: border-box 11 | tab-size: 4 12 | font: 87.5%/1.5 sans-serif 13 | letter-spacing: 0.02em 14 | color: $text-color 15 | @media (min-width: 600px) 16 | font-size: 100% 17 | @media (min-width: 800px) 18 | font-size: 112.5% 19 | @media (min-width: 1000px) 20 | font-size: 125% 21 | 22 | *, *:before, *:after 23 | box-sizing: inherit 24 | 25 | h1, h2, h3, h4, h5, h6 26 | margin: 1.5em 0 0.5em 27 | font-family: Georgia, serif 28 | 29 | h1, h2 30 | line-height: 1.2 31 | 32 | h1 33 | font-size: 1.75em 34 | 35 | h2 36 | font-size: 1.5em 37 | 38 | h3 39 | font-size: 1.1em 40 | 41 | p 42 | margin: 0.5em 0 43 | 44 | a 45 | color: $link-color 46 | text-decoration: none 47 | &:visited 48 | color: $link-visited-color 49 | &:hover, &:focus 50 | color: $link-color 51 | text-decoration: underline 52 | 53 | pre, code 54 | border-radius: 0.2em 55 | color: $background-color 56 | background: $text-color 57 | 58 | code 59 | padding: 0.1em 0.3em 60 | font-size: 0.9em 61 | 62 | pre 63 | margin: 2em 0 64 | padding: 0.5em 0.75em 65 | -webkit-overflow-scrolling: touch 66 | > code 67 | padding: 0 68 | -------------------------------------------------------------------------------- /styles/_normalize.sass: -------------------------------------------------------------------------------- 1 | html 2 | -ms-text-size-adjust: 100% 3 | -webkit-text-size-adjust: 100% 4 | 5 | body 6 | margin: 0 7 | 8 | article, 9 | aside, 10 | details, 11 | figcaption, 12 | figure, 13 | footer, 14 | header, 15 | hgroup, 16 | main, 17 | menu, 18 | nav, 19 | section, 20 | summary 21 | display: block 22 | 23 | a 24 | background-color: transparent 25 | 26 | &:active, 27 | &:hover 28 | outline: 0 29 | 30 | b, 31 | strong 32 | font-weight: bold 33 | 34 | img 35 | border: 0 36 | 37 | pre 38 | overflow: auto 39 | 40 | code, 41 | kbd, 42 | pre, 43 | samp 44 | font-family: monospace, monospace 45 | font-size: 1em 46 | -------------------------------------------------------------------------------- /styles/styles.sass: -------------------------------------------------------------------------------- 1 | @import "_normalize" 2 | @import "_common" 3 | @import "../node_modules/prismjs/themes/prism-okaidia" 4 | 5 | .page 6 | max-width: 50em 7 | margin: 4em auto 8 | padding: 0 2em 9 | 10 | .bookmarklet 11 | margin: 2em 0 12 | text-align: center 13 | 14 | &-link 15 | display: inline-block 16 | border: 0.1em solid $border-color 17 | border-radius: 1em 18 | padding: 0.6em 1em 0.4em 19 | font-size: 1.5em 20 | &:hover 21 | color: $background-color 22 | background-color: $border-color 23 | text-decoration: none 24 | 25 | &-desc 26 | margin: 1em 0 27 | font-size: 0.8em 28 | 29 | #view-settings 30 | vertical-align: middle 31 | + label 32 | margin: 0 0 0 0.2em 33 | 34 | .report 35 | max-width: 60em 36 | margin: 0 auto 37 | padding: 1em 2em 38 | 39 | > h1 40 | > a 41 | font-size: 0.6em 42 | white-space: nowrap 43 | 44 | &-item 45 | margin: 3em 0 46 | border-top: 1px solid $border-color 47 | 48 | h2 49 | color: $red 50 | 51 | &.-passed 52 | #view-settings:checked ~ & 53 | display: none 54 | 55 | h2 56 | color: $green 57 | 58 | &-header 59 | > img 60 | float: right 61 | max-width: 50% 62 | max-height: 4em 63 | 64 | .docs 65 | &-item 66 | margin: 4em 0 67 | border-top: 1px solid $border-color 68 | -------------------------------------------------------------------------------- /tests/util/computeLength.js: -------------------------------------------------------------------------------- 1 | import computeLength from '../../src/util/computeLength'; 2 | 3 | test('Basic px value', () => { 4 | expect(computeLength('123px', 1024)).toStrictEqual(123); 5 | }); 6 | 7 | test('Em values', () => { 8 | expect(computeLength('10em', 1024)).toStrictEqual(160); 9 | expect(computeLength('1.25em', 1024)).toStrictEqual(20); 10 | }); 11 | 12 | test('Rem values', () => { 13 | expect(computeLength('10rem', 1024)).toStrictEqual(160); 14 | expect(computeLength('1.25rem', 1024)).toStrictEqual(20); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/util/computeSrcsetWidths.js: -------------------------------------------------------------------------------- 1 | import computeSrcsetWidths from '../../src/util/computeSrcsetWidths'; 2 | 3 | // Test config from missingFittingSrc linter 4 | var config = { 5 | megapixelThreshold: 0.2, 6 | megapixelGap: 0.75, 7 | recommendedMinWidth: 256, 8 | recommendedMaxWidth: 2048, 9 | }; 10 | 11 | test('Basic srcset with single viewport', () => { 12 | expect(computeSrcsetWidths({'800x600': 400}, 1, 1, [])).toStrictEqual([400, 800, 1020, 1200]); 13 | }); 14 | 15 | test('Fluid 100vw square image', () => { 16 | const dimensions = {}; 17 | for (let viewport = 300; viewport < 3000; viewport+=10) { 18 | dimensions[viewport+'x'+viewport] = viewport; 19 | } 20 | 21 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], config); 22 | 23 | expect(widths).toStrictEqual([300, 880, 1210, 1470, 1680, 1870, 2048]); 24 | }); 25 | 26 | test('Fluid 100vw square image with existing widths', () => { 27 | const dimensions = {}; 28 | for (let viewport = 300; viewport < 3000; viewport+=10) { 29 | dimensions[viewport+'x'+viewport] = viewport; 30 | } 31 | 32 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [10, 1200, 1999, 9999], config); 33 | 34 | expect(widths).toStrictEqual([300, 880, 1200, 1440, 1650, 1830, 1999]); 35 | }); 36 | 37 | test('Fluid 100vw square image with common devices', () => { 38 | const dimensions = {}; 39 | for (let viewport = 300; viewport < 3000; viewport+=10) { 40 | dimensions[viewport+'x'+viewport] = viewport; 41 | } 42 | 43 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], { 44 | megapixelThreshold: config.megapixelThreshold, 45 | megapixelGap: config.megapixelGap, 46 | recommendedMinWidth: config.recommendedMinWidth, 47 | recommendedMaxWidth: config.recommendedMaxWidth, 48 | commonDevices: [ 49 | { width: 412, dpr: 1.75 }, 50 | { width: 360, dpr: 3 }, 51 | { width: 1920, dpr: 1 }, 52 | ], 53 | }); 54 | 55 | expect(widths).toStrictEqual([300, 721, 1080, 1340, 1560, 1750, 1920, 2048]); 56 | }); 57 | 58 | test('Fluid 50vw square image', () => { 59 | const dimensions = {}; 60 | for (let viewport = 300; viewport < 3000; viewport+=10) { 61 | dimensions[viewport+'x'+viewport] = viewport / 2; 62 | } 63 | 64 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], config); 65 | 66 | expect(widths).toStrictEqual([256, 890, 1230, 1500, 1700, 1880, 2048]); 67 | }); 68 | 69 | test('Fluid 100vw 4/1 image', () => { 70 | const dimensions = {}; 71 | for (let viewport = 300; viewport < 3000; viewport+=10) { 72 | dimensions[viewport+'x'+viewport] = viewport; 73 | } 74 | 75 | const widths = computeSrcsetWidths(dimensions, 1 / 4, Object.keys(dimensions).length, [], config); 76 | 77 | expect(widths).toStrictEqual([300, 1460, 2048]); 78 | }); 79 | 80 | test('Fluid 50vw 4/1 image', () => { 81 | const dimensions = {}; 82 | for (let viewport = 300; viewport < 3000; viewport+=10) { 83 | dimensions[viewport+'x'+viewport] = viewport / 2; 84 | } 85 | 86 | const widths = computeSrcsetWidths(dimensions, 1 / 4, Object.keys(dimensions).length, [], config); 87 | 88 | expect(widths).toStrictEqual([256, 1500, 2048]); 89 | }); 90 | 91 | test('Fluid 100vw square image max-width 600', () => { 92 | const dimensions = {}; 93 | for (let viewport = 300; viewport < 3000; viewport+=10) { 94 | dimensions[viewport+'x'+viewport] = Math.min(600, viewport); 95 | } 96 | 97 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], config); 98 | 99 | expect(widths).toStrictEqual([300, 600, 950, 1200, 1430, 1630, 1800]); 100 | }); 101 | 102 | test('Static 500 square image', () => { 103 | const dimensions = {}; 104 | for (let viewport = 300; viewport < 3000; viewport+=10) { 105 | dimensions[viewport+'x'+viewport] = 500; 106 | } 107 | 108 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], config); 109 | 110 | expect(widths).toStrictEqual([500, 1000, 1280, 1500]); 111 | }); 112 | 113 | test('Static 500/1000/1500 square image', () => { 114 | const dimensions = {}; 115 | for (let viewport = 300; viewport < 3000; viewport+=10) { 116 | dimensions[viewport+'x'+viewport] = Math.max(500, Math.min(1500, Math.floor(viewport / 500) * 500)); 117 | } 118 | 119 | const widths = computeSrcsetWidths(dimensions, 1, Object.keys(dimensions).length, [], config); 120 | 121 | // TODO: 1280, 1700 and 1880 should be removed for this case 122 | expect(widths).toStrictEqual([500, 1000, 1280, 1500, 1700, 1880, 2048]); 123 | }); 124 | -------------------------------------------------------------------------------- /tests/util/roundWidth.js: -------------------------------------------------------------------------------- 1 | import roundWidth from '../../src/util/roundWidth'; 2 | 3 | test('Does not round under 100', () => { 4 | expect(roundWidth(0)).toBe(0); 5 | expect(roundWidth(1)).toBe(1); 6 | expect(roundWidth(2)).toBe(2); 7 | expect(roundWidth(6)).toBe(6); 8 | expect(roundWidth(51)).toBe(51); 9 | expect(roundWidth(99)).toBe(99); 10 | }); 11 | 12 | test('Rounds to the next multiple of 5 under 500', () => { 13 | expect(roundWidth(101)).toBe(100); 14 | expect(roundWidth(102)).toBe(100); 15 | expect(roundWidth(103)).toBe(105); 16 | expect(roundWidth(104)).toBe(105); 17 | expect(roundWidth(105)).toBe(105); 18 | expect(roundWidth(497)).toBe(495); 19 | expect(roundWidth(499)).toBe(500); 20 | expect(roundWidth(500)).toBe(500); 21 | }); 22 | 23 | test('Rounds to the next multiple of 10 over 500', () => { 24 | expect(roundWidth(500)).toBe(500); 25 | expect(roundWidth(500)).toBe(500); 26 | expect(roundWidth(503)).toBe(500); 27 | expect(roundWidth(504)).toBe(500); 28 | expect(roundWidth(505)).toBe(510); 29 | expect(roundWidth(1230)).toBe(1230); 30 | expect(roundWidth(1234)).toBe(1230); 31 | expect(roundWidth(1235)).toBe(1240); 32 | expect(roundWidth(1240)).toBe(1240); 33 | }); 34 | 35 | test('Rounds to the next power of two', () => { 36 | expect(roundWidth(126)).toBe(125); 37 | expect(roundWidth(127)).toBe(128); 38 | expect(roundWidth(128)).toBe(128); 39 | expect(roundWidth(129)).toBe(130); 40 | expect(roundWidth(255)).toBe(255); 41 | expect(roundWidth(256)).toBe(256); 42 | expect(roundWidth(257)).toBe(256); 43 | expect(roundWidth(258)).toBe(260); 44 | expect(roundWidth(511)).toBe(510); 45 | expect(roundWidth(512)).toBe(512); 46 | expect(roundWidth(513)).toBe(512); 47 | expect(roundWidth(514)).toBe(512); 48 | expect(roundWidth(515)).toBe(512); 49 | expect(roundWidth(516)).toBe(520); 50 | expect(roundWidth(1022)).toBe(1020); 51 | expect(roundWidth(1023)).toBe(1024); 52 | expect(roundWidth(1024)).toBe(1024); 53 | expect(roundWidth(1025)).toBe(1024); 54 | expect(roundWidth(1026)).toBe(1024); 55 | expect(roundWidth(1027)).toBe(1030); 56 | }); 57 | --------------------------------------------------------------------------------