├── .gitignore ├── test ├── assets │ ├── image.png │ ├── image-2x.png │ ├── do_nothing.png │ ├── image-1440.png │ └── image-thumbnail.png ├── fixtures │ ├── testing.html │ └── testing2.html ├── expected │ ├── all.html │ ├── default_options.html │ ├── use_sizes.html │ ├── polyfill_lazyloading.html │ └── multi_src.html └── responsive_images_extender_test.js ├── .jshintrc ├── LICENSE-MIT ├── package.json ├── Gruntfile.js ├── tasks └── responsive_images_extender.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | tmp 4 | .nvmrc 5 | -------------------------------------------------------------------------------- /test/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanmax/grunt-responsive-images-extender/HEAD/test/assets/image.png -------------------------------------------------------------------------------- /test/assets/image-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanmax/grunt-responsive-images-extender/HEAD/test/assets/image-2x.png -------------------------------------------------------------------------------- /test/assets/do_nothing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanmax/grunt-responsive-images-extender/HEAD/test/assets/do_nothing.png -------------------------------------------------------------------------------- /test/assets/image-1440.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanmax/grunt-responsive-images-extender/HEAD/test/assets/image-1440.png -------------------------------------------------------------------------------- /test/assets/image-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanmax/grunt-responsive-images-extender/HEAD/test/assets/image-thumbnail.png -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "latedef": true, 6 | "newcap": true, 7 | "noarg": true, 8 | "sub": true, 9 | "undef": true, 10 | "boss": true, 11 | "eqnull": true, 12 | "node": true 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/testing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/fixtures/testing2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/LICENSE-MIT:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014 Stephan Max
2 |
3 | Permission is hereby granted, free of charge, to any person
4 | obtaining a copy of this software and associated documentation
5 | files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use,
7 | copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the
9 | Software is furnished to do so, subject to the following
10 | conditions:
11 |
12 | The above copyright notice and this permission notice shall be
13 | included in all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "grunt-responsive-images-extender",
3 | "description": "Extend HTML image tags with srcset and sizes attributes to leverage native responsive images.",
4 | "version": "3.0.0",
5 | "homepage": "https://github.com/stephanmax/grunt-responsive-images-extender",
6 | "author": {
7 | "name": "Stephan Max",
8 | "email": "stephan.max@gmail.com",
9 | "url": "http://stephanmax.is"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git://github.com/stephanmax/grunt-responsive-images-extender.git"
14 | },
15 | "bugs": {
16 | "url": "https://github.com/stephanmax/grunt-responsive-images-extender/issues"
17 | },
18 | "license": "MIT",
19 | "engines": {
20 | "node": ">= 6.0.0"
21 | },
22 | "scripts": {
23 | "test": "grunt test"
24 | },
25 | "devDependencies": {
26 | "grunt": "~0.4.0",
27 | "grunt-contrib-clean": "^0.5.0",
28 | "grunt-contrib-jshint": "^0.9.2",
29 | "grunt-contrib-nodeunit": "^0.3.3"
30 | },
31 | "peerDependencies": {
32 | "grunt": ">=0.4.0"
33 | },
34 | "keywords": [
35 | "gruntplugin",
36 | "grunt",
37 | "responsive",
38 | "responsivedesign",
39 | "rwd",
40 | "img",
41 | "image",
42 | "images",
43 | "extend",
44 | "convert",
45 | "converter",
46 | "srcset",
47 | "sizes"
48 | ],
49 | "dependencies": {
50 | "cheerio": "^0.19.0",
51 | "image-size": "^0.3.5"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/test/expected/all.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
16 |
17 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/expected/default_options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/expected/use_sizes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/expected/polyfill_lazyloading.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/responsive_images_extender_test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var grunt = require('grunt');
4 |
5 | exports.responsive_images_extender = {
6 | setUp: function(done) {
7 | done();
8 | },
9 | default_options: function(test) {
10 | test.expect(1);
11 |
12 | var actual = grunt.file.read('test/tmp/default_options.html');
13 | var expected = grunt.file.read('test/expected/default_options.html');
14 | test.equal(actual, expected, 'Should describe what the default behavior is.');
15 |
16 | test.done();
17 | },
18 | retina: function(test) {
19 | test.expect(1);
20 |
21 | var actual = grunt.file.read('test/tmp/polyfill_lazyloading.html');
22 | var expected = grunt.file.read('test/expected/polyfill_lazyloading.html');
23 | test.equal(actual, expected, 'Should describe what the polyfill and lazyloading behavior is.');
24 |
25 | test.done();
26 | },
27 | use_sizes: function(test) {
28 | test.expect(1);
29 |
30 | var actual = grunt.file.read('test/tmp/use_sizes.html');
31 | var expected = grunt.file.read('test/expected/use_sizes.html');
32 | test.equal(actual, expected, 'Should describe what the sizes attribute behavior is.');
33 |
34 | test.done();
35 | },
36 | all: function(test) {
37 | test.expect(1);
38 |
39 | var actual = grunt.file.read('test/tmp/all.html');
40 | var expected = grunt.file.read('test/expected/all.html');
41 | test.equal(actual, expected, 'Should describe what the complete behavior is.');
42 |
43 | test.done();
44 | },
45 | multi_src: function(test) {
46 | test.expect(1);
47 |
48 | var actual = grunt.file.read('test/tmp/multi_src.html');
49 | var expected = grunt.file.read('test/expected/multi_src.html');
50 | test.equal(actual, expected, 'Should describe what the multi file src behavior is.');
51 |
52 | test.done();
53 | }
54 | };
55 |
--------------------------------------------------------------------------------
/test/expected/multi_src.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
63 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/Gruntfile.js:
--------------------------------------------------------------------------------
1 | /*
2 | * grunt-responsive-images-extender
3 | * https://github.com/smaxtastic/grunt-responsive-images-extender
4 | *
5 | * Copyright (c) 2014 Stephan Max
6 | * Licensed under the MIT license.
7 | */
8 |
9 | 'use strict';
10 |
11 | module.exports = function(grunt) {
12 |
13 | grunt.initConfig({
14 | jshint: {
15 | all: [
16 | 'Gruntfile.js',
17 | 'tasks/*.js',
18 | '<%= nodeunit.tests %>'
19 | ],
20 | options: {
21 | jshintrc: '.jshintrc'
22 | }
23 | },
24 |
25 | clean: {
26 | tests: ['test/tmp']
27 | },
28 |
29 | // Four example configurations to be run (and then tested)
30 | responsive_images_extender: {
31 | options: {
32 | baseDir: 'test'
33 | },
34 | default_options: {
35 | files: [{
36 | src: ['test/fixtures/testing.html'],
37 | dest: 'test/tmp/default_options.html'
38 | }]
39 | },
40 | use_sizes: {
41 | options: {
42 | sizes: [{
43 | selector: '.fig-hero img',
44 | sizeList: [{
45 | cond: 'max-width: 30em',
46 | size: '100vw'
47 | },{
48 | cond: 'max-width: 50em',
49 | size: '50vw'
50 | },{
51 | cond: 'default',
52 | size: 'calc(33vw - 100px)'
53 | }]
54 | },{
55 | selector: '[alt]',
56 | sizeList: [{
57 | cond: 'max-width: 20em',
58 | size: '80vw'
59 | },{
60 | cond: 'default',
61 | size: '90vw'
62 | }]
63 | }]
64 | },
65 | files: [{
66 | src: ['test/fixtures/testing.html'],
67 | dest: 'test/tmp/use_sizes.html'
68 | }]
69 | },
70 | polyfill_lazyloading: {
71 | options: {
72 | srcsetAttributeName: 'data-srcset',
73 | srcAttribute: 'none'
74 | },
75 | files: [{
76 | src: ['test/fixtures/testing.html'],
77 | dest: 'test/tmp/polyfill_lazyloading.html'
78 | }]
79 | },
80 | all: {
81 | options: {
82 | ignore: ['.ignore-me'],
83 | srcAttribute: 'smallest',
84 | sizes: [{
85 | selector: '.fig-hero img',
86 | sizeList: [{
87 | cond: 'max-width: 30em',
88 | size: '100vw'
89 | },{
90 | cond: 'max-width: 50em',
91 | size: '50vw'
92 | },{
93 | cond: 'default',
94 | size: 'calc(33vw - 100px)'
95 | }]
96 | }]
97 | },
98 | files: [{
99 | src: ['test/fixtures/testing.html'],
100 | dest: 'test/tmp/all.html'
101 | }]
102 | },
103 | multi_src: {
104 | files: [{
105 | src: ['test/fixtures/testing.html', 'test/fixtures/testing2.html'],
106 | dest: 'test/tmp/multi_src.html'
107 | }]
108 | }
109 | },
110 |
111 | nodeunit: {
112 | tests: ['test/*_test.js']
113 | }
114 |
115 | });
116 |
117 | grunt.loadTasks('tasks');
118 |
119 | grunt.loadNpmTasks('grunt-contrib-jshint');
120 | grunt.loadNpmTasks('grunt-contrib-clean');
121 | grunt.loadNpmTasks('grunt-contrib-nodeunit');
122 |
123 | grunt.registerTask('test', ['clean', 'responsive_images_extender', 'nodeunit']);
124 |
125 | grunt.registerTask('default', ['jshint', 'test']);
126 | };
127 |
--------------------------------------------------------------------------------
/tasks/responsive_images_extender.js:
--------------------------------------------------------------------------------
1 | /*
2 | * grunt-responsive-images-extender
3 | * https://github.com/smaxtastic/grunt-responsive-images-extender
4 | *
5 | * Copyright (c) 2014 Stephan Max
6 | * Licensed under the MIT license.
7 | *
8 | * Extend HTML image tags with srcset and sizes attributes to leverage native responsive images.
9 | *
10 | * @author Stephan Max (http://stephanmax.is)
11 | * @version 2.0.0
12 | */
13 |
14 | module.exports = function(grunt) {
15 | 'use strict';
16 |
17 | var fs = require('fs');
18 | var path = require('path');
19 | var cheerio = require('cheerio');
20 | var sizeOf = require('image-size');
21 |
22 | var DEFAULT_OPTIONS = {
23 | separator: '-',
24 | baseDir: '',
25 | ignore: [],
26 | srcsetAttributeName: 'srcset'
27 | };
28 |
29 | grunt.registerMultiTask('responsive_images_extender', 'Extend HTML image tags with srcset and sizes attributes to leverage native responsive images.', function() {
30 | var numOfFiles = this.files.length;
31 | var options = this.options(DEFAULT_OPTIONS);
32 | var imgCount = 0;
33 |
34 | var parseAndExtendImg = function(filepath) {
35 | var content = grunt.file.read(filepath);
36 | var $ = cheerio.load(content, {decodeEntities: false});
37 | var imgElems = $('img:not(' + options.ignore.join(', ') + ')');
38 |
39 | imgElems.each(function() {
40 | var normalizeImagePath = function(src) {
41 | var pathPrefix;
42 |
43 | if (path.isAbsolute(src)) {
44 | pathPrefix = options.baseDir;
45 | }
46 | else {
47 | pathPrefix = path.dirname(filepath);
48 | }
49 |
50 | return path.parse(path.join(pathPrefix, src));
51 | };
52 |
53 | var findMatchingImages = function(path) {
54 | var files = fs.readdirSync(path.dir);
55 | var imageMatch = new RegExp(path.name + '(' + options.separator + '[^' + options.separator + ']*)?' + path.ext + '$');
56 |
57 | return files.filter(function(filename) {
58 | return imageMatch.test(filename);
59 | });
60 | };
61 |
62 | var buildSrcMap = function(imageNames) {
63 | var srcMap = {};
64 |
65 | imageNames.forEach(function(imageName) {
66 | srcMap[imageName] = sizeOf(path.join(imagePath.dir, imageName)).width;
67 | });
68 |
69 | return srcMap;
70 | };
71 |
72 | var buildSrcset = function(srcMap, width) {
73 | var srcset = [];
74 | var candidate;
75 |
76 | for (var img in srcMap) {
77 | candidate = path.posix.join(path.dirname(imgSrc), img);
78 | if (width !== undefined) {
79 | candidate += ' ' + Math.round(srcMap[img] / width * 100) / 100 + 'x';
80 | }
81 | else {
82 | candidate += ' ' + srcMap[img] + 'w';
83 | }
84 | srcset.push(candidate);
85 | }
86 |
87 | if (options.srcsetAttributeName !== DEFAULT_OPTIONS.srcsetAttributeName) {
88 | imgElem.attr(DEFAULT_OPTIONS.srcsetAttributeName, null);
89 | }
90 |
91 | return srcset.join(', ');
92 | };
93 |
94 | var buildSizes = function(sizeList) {
95 | var sizes = [];
96 |
97 | sizeList.forEach(function(s) {
98 | var actualSize = srcMap[imagePath.name + imagePath.ext] + 'px';
99 | var cond = s.cond.replace('%size%', actualSize);
100 | var size = s.size.replace('%size%', actualSize);
101 |
102 | sizes.push(
103 | cond === 'default' ? size : '(' + cond + ') ' + size
104 | );
105 | });
106 |
107 | return sizes.join(', ');
108 | };
109 |
110 | var setSrcAttribute = function() {
111 | switch (options.srcAttribute) {
112 | case 'none':
113 | imgElem.attr('src', null);
114 | break;
115 | case 'smallest':
116 | var smallestImage = Object.keys(srcMap).map(function(k) {
117 | return [k, srcMap[k]];
118 | }).reduce(function(a, b) {
119 | return b[1] < a[1] ? b : a;
120 | });
121 | imgElem.attr('src', path.posix.join(path.dirname(imgSrc), smallestImage[0]));
122 | break;
123 | default:
124 | }
125 | };
126 |
127 | var imgElem = $(this);
128 | var imgWidth = imgElem.attr('width');
129 | var imgSrc = imgElem.attr('src');
130 |
131 | var useSizes = 'sizes' in options;
132 | var isResponsive = imgWidth === undefined;
133 | var hasSrcset = imgElem.attr(options.srcsetAttributeName) !== undefined;
134 | var hasSizes = imgElem.attr('sizes') !== undefined;
135 |
136 | var imagePath;
137 | var imageMatches;
138 | var srcMap;
139 |
140 | if (hasSrcset && (!isResponsive || (isResponsive && hasSizes) || !useSizes)) {
141 | return;
142 | }
143 |
144 | imagePath = normalizeImagePath(imgSrc);
145 | imageMatches = findMatchingImages(imagePath);
146 |
147 | switch (imageMatches.length) {
148 | case 0:
149 | grunt.verbose.error('Found no file for ' + imgSrc.cyan);
150 | return;
151 | case 1:
152 | grunt.verbose.error('Found only one file for ' + imgSrc.cyan);
153 | return;
154 | default:
155 | grunt.verbose.ok('Found ' + imageMatches.length.cyan + ' files for ' + imgSrc.cyan + ': ' + imageMatches);
156 | }
157 |
158 | srcMap = buildSrcMap(imageMatches);
159 |
160 | if (!isResponsive && imgWidth > 0) {
161 | imgElem.attr(options.srcsetAttributeName, buildSrcset(srcMap, imgWidth));
162 | setSrcAttribute();
163 | }
164 | else {
165 | if (!hasSrcset) {
166 | imgElem.attr(options.srcsetAttributeName, buildSrcset(srcMap));
167 | setSrcAttribute();
168 | }
169 |
170 | if (!hasSizes && useSizes) {
171 | options.sizes.some(function (s) {
172 | if (imgElem.is(s.selector)) {
173 | imgElem.attr('sizes', buildSizes(s.sizeList));
174 | setSrcAttribute();
175 | return true;
176 | }
177 | });
178 | }
179 | }
180 | });
181 |
182 | return {content: $.html(), count: imgElems.length};
183 | };
184 |
185 | this.files.forEach(function(file) {
186 | var contents = file.src.filter(function(filepath) {
187 | if (!grunt.file.exists(filepath)) {
188 | grunt.log.warn('Source file "' + filepath + '" not found.');
189 | return false;
190 | } else {
191 | return true;
192 | }
193 | }).map(function(filepath) {
194 | var result = parseAndExtendImg(filepath);
195 | imgCount += result.count;
196 | return result.content;
197 | }).join('\n');
198 |
199 | grunt.file.write(file.dest, contents);
200 | });
201 |
202 | grunt.log.ok('Processed ' + imgCount.toString().cyan + '
144 | ```
145 |
146 | into this (the image sizes are arbitrarily chosen and read directly from the files):
147 |
148 | ```html
149 |
155 | ```
156 |
157 | #### Custom Options
158 | Use the options to refine your tasks, e.g. to add a `sizes` attribute, a different separator, or a different `src` value. `
197 |
198 |
199 | ```
200 |
201 | into this:
202 |
203 | ```html
204 |
212 |
213 |
218 | ```
219 |
220 | #### Ignoring images
221 | Sometimes you want to exclude certain images from the algorithm. You can achieve this with the `ignore` option:
222 |
223 | ```js
224 | grunt.initConfig({
225 | responsive_images_extender: {
226 | ignoring: {
227 | options: {
228 | ignore: ['.icons', '#logo', 'figure img']
229 | },
230 | files: [{
231 | expand: true,
232 | src: ['**/*.{html,htm,php}'],
233 | cwd: 'src/',
234 | dest: 'build/'
235 | }]
236 | }
237 | }
238 | });
239 | ```
240 |
241 | Please see this task's [Gruntfile](https://github.com/smaxtastic/grunt-responsive-images-extender/blob/master/Gruntfile.js) for more usage examples.
242 |
243 | ## Related Work
244 |
245 | * **grunt-responsive-images**
246 |
247 | Use this [task](https://github.com/andismith/grunt-responsive-images/) to generate images with different sizes.
248 |
249 | * **grunt-responsive-images-converter**
250 |
251 | This [task](https://github.com/miller/grunt-responsive-images-converter/) can be used to convert images in markdown files into a `
14 |
15 |
27 |