├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── index.js ├── package.json └── test ├── expectations ├── A.css ├── B.css ├── sprite.png ├── stylesheet.css ├── stylesheet.filter.css ├── stylesheet.groupby.css └── stylesheet.retina.css ├── fixtures ├── A.css ├── B.css ├── a.png ├── a@2x.png ├── b.png ├── b@2x.png ├── stylesheet.css └── stylesheet.retina.css └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tmp 3 | *.iml 4 | node_modules 5 | npm-debug.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tmp 3 | *.iml 4 | node_modules 5 | npm-debug.log -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # THE CHANGELOG 2 | 3 | ### 0.2.3 (05.12.2014) 4 | ______________________ 5 | 6 | + Added option for accumulate images from multiple stylesheets; 7 | + Removed annoying logs (`verbose` option) 8 | 9 | ### 0.2.2 (25.11.2014) 10 | ______________________ 11 | 12 | + Duplicating images bugfix [issue#6](https://github.com/gobwas/gulp-sprite-generator/issues/6); 13 | + Travis CI integration; 14 | + Minimal refactoring. 15 | 16 | ### 0.2.0 (18.05.2014) 17 | ______________________ 18 | 19 | + Windows path bugfix [issue#2](https://github.com/gobwas/gulp-sprite-generator/issues/2); 20 | + CSS and IMG streams are now flushing properly [issue#1](https://github.com/gobwas/gulp-sprite-generator/issues/1); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [gulp](http://gulpjs.com)-sprite-generator 2 | 3 | [![npm version](https://badge.fury.io/js/gulp-sprite-generator.svg)](http://badge.fury.io/js/gulp-sprite-generator) 4 | [![Build Status](https://travis-ci.org/gobwas/gulp-sprite-generator.svg?branch=master)](https://travis-ci.org/gobwas/gulp-sprite-generator) 5 | 6 | > Generate sprites from stylesheets. 7 | 8 | Plugin that generate sprites from your stylesheets (using [spritesmith](https://github.com/Ensighten/spritesmith)) and then updates image references. 9 | 10 | ## Getting started 11 | 12 | If you haven't used [gulp](http://gulpjs.com) before, be sure to check out the [Getting Started](https://github.com/gulpjs/gulp/blob/master/docs/getting-started.md) guide. 13 | 14 | Install with [npm](https://npmjs.org/package/gulp-sprite-generator) 15 | 16 | ``` 17 | npm install --save-dev gulp-sprite-generator 18 | ``` 19 | 20 | ## Overview 21 | 22 | Sprite generator is a gulp task, which accepts options object and returns two streams for style and image piping. 23 | 24 | Here quick example of simple way usage: 25 | 26 | ```javascript 27 | var gulp = require('gulp'); 28 | var sprite = require('gulp-sprite-generator'); 29 | 30 | gulp.task('sprites', function() { 31 | var spriteOutput; 32 | 33 | spriteOutput = gulp.src("./src/css/*.css") 34 | .pipe(sprite({ 35 | baseUrl: "./src/image", 36 | spriteSheetName: "sprite.png", 37 | spriteSheetPath: "/dist/image" 38 | })); 39 | 40 | spriteOutput.css.pipe(gulp.dest("./dist/css")); 41 | spriteOutput.img.pipe(gulp.dest("./dist/image")); 42 | }); 43 | ``` 44 | 45 | Of course you may need to have more flexible configuration for spriting. And this plugin can give you something more! 46 | 47 | ## Options 48 | 49 | Sprite generator options is an object, that mix [spritesmith](https://github.com/Ensighten/spritesmith) 50 | options and plugin specific options. 51 | 52 | **Spritesmith parameters** *(all is optional)*: 53 | 54 | Property | Necessary | Type | Plugin default value 55 | -------------|-----------|----------|--------------------- 56 | [engine] | no | `String` | `"pngsmith"` 57 | [algorithm] | no | `String` | `"top-down"` 58 | [padding] | no | `Number` | `0` 59 | [engineOpts] | no | `Object` | `{}` 60 | [exportOpts] | no | `Object` | `{}` 61 | 62 | More detailed explanation you can find on the [official page of spritesmith](https://github.com/Ensighten/spritesmith). 63 | 64 | **Plugin options** are: 65 | 66 | Property | Necessary | Type | Plugin default value 67 | ------------------|-----------|--------------|----------- 68 | spriteSheetName | **yes** | `String` | `null` 69 | [spriteSheetPath] | no | `String` | `null` 70 | [styleSheetName] | np | `String` | `null` 71 | [baseUrl] | no | `String` | `"./"` 72 | [retina] | no | `Boolean` | `true` 73 | [filter] | no | `Function[]` | `[]` 74 | [groupBy] | no | `Function[]` | `[]` 75 | [accumulate] | no | `Boolean` | `false` 76 | [verbose] | no | `Boolean` | `false` 77 | 78 | More detailed explanation is below. 79 | 80 | #### options.spriteSheetName 81 | Type: `String` 82 | Default value: `null` 83 | 84 | The one and last necessary parameter. Defines which *base* will have the name of the output sprite. Base means that if you will 85 | group your sprites by some criteria, name will change. 86 | 87 | #### options.spriteSheetPath 88 | Type: `String` 89 | Default value: `null` 90 | 91 | Can define relative path of references in the output stylesheet. 92 | 93 | #### options.styleSheetName 94 | Type: `String` 95 | Default value: `null` 96 | 97 | Defines the name of the output stylesheet. 98 | 99 | #### options.baseUrl 100 | Type: `String` 101 | Default value: `./` 102 | 103 | Defines where to find relatively defined image references in the input stylesheet. 104 | 105 | #### options.retina 106 | Type: `Boolean` 107 | Default value: `true` 108 | 109 | Defines whether or not to search for retina mark in the filename. If `true` then it will look for `@{number}x` syntax. 110 | For example: `image@2x.png`. 111 | 112 | #### options.filter 113 | Type: `Function[]`, `Function` 114 | Default value: `[]` 115 | 116 | Defines which filters apply to images found in the input stylesheet. Each filer called with image object, explained below. Each filter must return `Boolean` or 117 | [thenable `Promise`](https://github.com/promises-aplus/promises-spec), that will be resolved with `Boolean`. Each filter 118 | applies in series. 119 | 120 | #### options.groupBy 121 | Type: `Function[]`, `Function` 122 | Default value: `[]` 123 | 124 | Defines logic of how to group images found in the input stylesheet. Each grouper called with image object, explained below. Each filter must return `String|Null` or 125 | [thenable `Promise`](https://github.com/promises-aplus/promises-spec), that will be resolved with `String|Null`. Each grouper 126 | applies in series. 127 | 128 | #### options.accumulate 129 | Type: `Boolean` 130 | Default value: `false` 131 | 132 | Tells sprite-generator to accumulate images from multiple stylesheets. This mean, that images, found in stylesheet `A.css` and `B.css` will be accumulated and grouped in common sprite. 133 | > Note, that if `options.accumulate == true` then `options.styleSheetName` will not be used. 134 | 135 | #### options.verbose 136 | Type: `Boolean` 137 | Default value: `false` 138 | 139 | ### Filtering and grouping 140 | 141 | Sprite generator can filter and group images from the input stylesheet. 142 | 143 | Built in filters: 144 | - based on meta `skip` boolean flag; 145 | - based on `fs.exists` method to check, whether file exists or not. 146 | 147 | Built in groupers: 148 | - based on @2x image naming syntax, will produce `sprite.@{number}x.png` naming. (`@{number}x` image group). 149 | 150 | You can of course define your own filters or groupers. It will all based on main argument - the image object. 151 | 152 | ### The Image object 153 | 154 | Every filter or grouper is called with `image` object, that have these properties: 155 | 156 | Property | Type | Explanation 157 | ------------|------------|--------------------- 158 | replacement | `String` | String, found by pattern in the input stylesheet 159 | url | `String` | Url for image fount in the input stylesheet 160 | path | `String` | Resolved path for the image 161 | group | `String[]` | List of string, representing groups of image 162 | isRetina | `Boolean` | Boolean flag of retina image (@2x syntax) 163 | retinaRatio | `Number` | Ratio of retina image (@2x, @3x => 2, 3) 164 | meta | `Object` | Object of meta properties, defined in doc block (will explain below). 165 | 166 | 167 | ### Doc block meta properties 168 | 169 | You can also define some properties for the filters and groupers in doc block via this syntax: 170 | 171 | `{css definition} /* @meta {valid json} */` 172 | 173 | Example: 174 | 175 | ```css 176 | 177 | .my_class { 178 | background-image: url("/images/my.png"); /* @meta {"sprite": {"skip": true}} */ 179 | } 180 | 181 | ``` 182 | 183 | ***Important!*** Only object in `sprite` property of meta will be available in image object for filters and groupers. 184 | 185 | 186 | ### Flexible example 187 | 188 | ```javascript 189 | 190 | var gulp = require('gulp'), 191 | sprite = require('gulp-sprite-generator'), 192 | Q = require('q'), 193 | sizeOf = require('image-size'); 194 | 195 | gulp.task('sprites', function() { 196 | var spriteOutput; 197 | 198 | spriteOutput = gulp.src("./src/css/*.css") 199 | .pipe(sprite({ 200 | baseUrl: "./", 201 | spriteSheetName: "sprite.png", 202 | spriteSheetPath: "/dist/image", 203 | styleSheetName: "stylesheet.css", 204 | 205 | filter: [ 206 | // this is a copy of built in filter of meta skip 207 | // do not forget to set it up in your stylesheets using doc block /* */ 208 | function(image) { 209 | return !image.meta.skip; 210 | } 211 | ], 212 | 213 | groupBy: [ 214 | // group images by width 215 | // useful when building background repeatable sprites 216 | function(image) { 217 | var deferred = Q.defer(); 218 | 219 | sizeOf(image.path, function(err, size) { 220 | deferred.resolve(size.width.toString()); 221 | }); 222 | 223 | return deferred.promise; 224 | } 225 | ] 226 | }); 227 | 228 | spriteOutput.css.pipe(gulp.dest("./dist/css")); 229 | spriteOutput.img.pipe(gulp.dest("./dist/image")); 230 | }); 231 | 232 | ``` 233 | 234 | ## License 235 | 236 | MIT © [Sergey Kamardin](http://github.com/gobwas) 237 | 238 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | spritesmith = require('spritesmith'), 3 | File = require('vinyl'), 4 | _ = require('lodash'), 5 | colors = require('colors'), 6 | fs = require('fs'), 7 | gutil = require('gulp-util'), 8 | util = require("util"), 9 | async = require('async'), 10 | Q = require('q'), 11 | through = require('through2'), 12 | Readable = require('stream').Readable, 13 | 14 | PLUGIN_NAME = "gulp-sprite-generator", 15 | debug; 16 | 17 | var log = function() { 18 | var args, sig; 19 | 20 | args = Array.prototype.slice.call(arguments); 21 | sig = '[' + colors.green(PLUGIN_NAME) + ']'; 22 | args.unshift(sig); 23 | 24 | gutil.log.apply(gutil, args); 25 | }; 26 | 27 | var getImages = (function() { 28 | var httpRegex, imageRegex, filePathRegex, pngRegex, retinaRegex; 29 | 30 | imageRegex = new RegExp('background-image:[\\s]?url\\(["\']?([\\w\\d\\s!:./\\-\\_@]*\\.[\\w?#]+)["\']?\\)[^;]*\\;(?:\\s*\\/\\*\\s*@meta\\s*(\\{.*\\})\\s*\\*\\/)?', 'ig'); 31 | retinaRegex = new RegExp('@(\\d)x\\.[a-z]{3,4}$', 'ig'); 32 | httpRegex = new RegExp('http[s]?', 'ig'); 33 | pngRegex = new RegExp('\\.png$', 'ig'); 34 | filePathRegex = new RegExp('["\']?([\\w\\d\\s!:./\\-\\_@]*\\.[\\w?#]+)["\']?', 'ig'); 35 | 36 | return function(file, options) { 37 | var reference, images, 38 | retina, filePath, 39 | url, image, meta, basename, 40 | makeRegexp, content; 41 | 42 | content = file.contents.toString(); 43 | 44 | images = []; 45 | 46 | basename = path.basename(file.path); 47 | 48 | makeRegexp = (function() { 49 | var matchOperatorsRe = /[|\\/{}()[\]^$+*?.]/g; 50 | 51 | return function(str) { 52 | return str.replace(matchOperatorsRe, '\\$&'); 53 | } 54 | })(); 55 | 56 | while ((reference = imageRegex.exec(content)) != null) { 57 | url = reference[1]; 58 | meta = reference[2]; 59 | 60 | image = { 61 | replacement: new RegExp('background-image:\\s+url\\(\\s?(["\']?)\\s?' + makeRegexp(url) + '\\s?\\1\\s?\\)[^;]*\\;', 'gi'), 62 | url: url, 63 | group: [], 64 | isRetina: false, 65 | retinaRatio: 1, 66 | meta: {} 67 | }; 68 | 69 | if (httpRegex.test(url)) { 70 | options.verbose && log(colors.cyan(basename) + ' > ' + url + ' has been skipped as it\'s an external resource!'); 71 | continue; 72 | } 73 | 74 | if (!pngRegex.test(url)) { 75 | options.verbose && log(colors.cyan(basename) + ' > ' + url + ' has been skipped as it\'s not a PNG!'); 76 | continue; 77 | } 78 | 79 | if (meta) { 80 | try { 81 | meta = JSON.parse(meta); 82 | meta.sprite && (image.meta = meta.sprite); 83 | } catch (err) { 84 | log(colors.cyan(basename) + ' > ' + colors.white('Can not parse meta json for ' + url) + ': "' + colors.red(err) + '"'); 85 | } 86 | } 87 | 88 | if (options.retina && (retina = retinaRegex.exec(url))) { 89 | image.isRetina = true; 90 | image.retinaRatio = retina[1]; 91 | } 92 | 93 | filePath = filePathRegex.exec(url)[0].replace(/['"]/g, ''); 94 | 95 | // if url to image is relative 96 | if(filePath.charAt(0) === "/") { 97 | filePath = path.resolve(options.baseUrl + filePath); 98 | } else { 99 | filePath = path.resolve(file.path.substring(0, file.path.lastIndexOf(path.sep)), filePath); 100 | } 101 | 102 | image.path = filePath; 103 | 104 | // reset lastIndex 105 | [httpRegex, pngRegex, retinaRegex, filePathRegex].forEach(function(regex) { 106 | regex.lastIndex = 0; 107 | }); 108 | 109 | images.push(image); 110 | } 111 | 112 | // reset lastIndex 113 | imageRegex.lastIndex = 0; 114 | 115 | // remove nulls and duplicates 116 | images = _.chain(images) 117 | .filter() 118 | .unique(function(image) { 119 | return image.path; 120 | }) 121 | .value(); 122 | 123 | return Q(images) 124 | // apply user filters 125 | .then(function(images) { 126 | return Q.Promise(function(resolve, reject) { 127 | async.reduce( 128 | options.filter, 129 | images, 130 | function(images, filter, next) { 131 | async.filter( 132 | images, 133 | function(image, ok) { 134 | Q(filter(image)).then(ok); 135 | }, 136 | function(images) { 137 | next(null, images); 138 | } 139 | ); 140 | }, 141 | function(err, images) { 142 | if (err) { 143 | return reject(err); 144 | } 145 | 146 | resolve(images); 147 | } 148 | ); 149 | }); 150 | }) 151 | // apply user group processors 152 | .then(function(images) { 153 | return Q.Promise(function(resolve, reject) { 154 | async.reduce( 155 | options.groupBy, 156 | images, 157 | function(images, groupBy, next) { 158 | async.map(images, function(image, done) { 159 | Q(groupBy(image)) 160 | .then(function(group) { 161 | if (group) { 162 | image.group.push(group); 163 | } 164 | 165 | done(null, image); 166 | }) 167 | .catch(done); 168 | }, next); 169 | }, 170 | function(err, images) { 171 | if (err) { 172 | return reject(err); 173 | } 174 | 175 | resolve(images); 176 | } 177 | ); 178 | }); 179 | }); 180 | } 181 | })(); 182 | 183 | var callSpriteSmithWith = (function() { 184 | var GROUP_DELIMITER = ".", 185 | GROUP_MASK = "*"; 186 | 187 | // helper function to minimize user group names symbols collisions 188 | function mask(toggle) { 189 | var from, to; 190 | 191 | from = new RegExp("[" + (toggle ? GROUP_DELIMITER : GROUP_MASK) + "]", "gi"); 192 | to = toggle ? GROUP_MASK : GROUP_DELIMITER; 193 | 194 | return function(value) { 195 | return value.replace(from, to); 196 | } 197 | } 198 | 199 | return function(images, options) { 200 | var all; 201 | 202 | all = _.chain(images) 203 | .groupBy(function(image) { 204 | var tmp; 205 | 206 | tmp = image.group.map(mask(true)); 207 | tmp.unshift('_'); 208 | 209 | return tmp.join(GROUP_DELIMITER); 210 | }) 211 | .map(function(images, tmp) { 212 | var config, ratio; 213 | 214 | config = _.merge({}, options, { 215 | src: _.pluck(images, 'path') 216 | }); 217 | 218 | // enlarge padding, if its retina 219 | if (_.every(images, function(image) {return image.isRetina})) { 220 | ratio = _.chain(images).flatten('retinaRatio').unique().value(); 221 | if (ratio.length == 1) { 222 | config.padding = config.padding * ratio[0]; 223 | } 224 | } 225 | 226 | return Q.nfcall(spritesmith, config).then(function(result) { 227 | tmp = tmp.split(GROUP_DELIMITER); 228 | tmp.shift(); 229 | 230 | // append info about sprite group 231 | result.group = tmp.map(mask(false)); 232 | 233 | return result; 234 | }); 235 | }) 236 | .value(); 237 | 238 | 239 | return Q.all(all).then(function(results) { 240 | debug.images+= images.length; 241 | debug.sprites+= results.length; 242 | return results; 243 | }); 244 | } 245 | })(); 246 | 247 | var updateReferencesIn = (function() { 248 | var template; 249 | 250 | template = _.template( 251 | 'background-image: url("<%= spriteSheetPath %>");\n ' + 252 | 'background-position: -<%= isRetina ? (coordinates.x / retinaRatio) : coordinates.x %>px -<%= isRetina ? (coordinates.y / retinaRatio) : coordinates.y %>px;\n ' + 253 | 'background-size: <%= isRetina ? (properties.width / retinaRatio) : properties.width %>px <%= isRetina ? (properties.height / retinaRatio) : properties.height %>px!important;' 254 | ); 255 | 256 | return function(file) { 257 | var content = file.contents.toString(); 258 | 259 | return function(results) { 260 | results.forEach(function(images) { 261 | images.forEach(function(image) { 262 | content = content.replace(image.replacement, template(image)); 263 | }); 264 | }); 265 | 266 | return Q(content); 267 | } 268 | } 269 | })(); 270 | 271 | var exportSprites = (function() { 272 | function makeSpriteSheetPath(spriteSheetName, group) { 273 | var path; 274 | 275 | group || (group = []); 276 | 277 | if (group.length == 0) { 278 | return spriteSheetName; 279 | } 280 | 281 | path = spriteSheetName.split('.'); 282 | Array.prototype.splice.apply(path, [path.length - 1, 0].concat(group)); 283 | 284 | return path.join('.'); 285 | } 286 | 287 | return function(stream, options) { 288 | return function(results) { 289 | results = results.map(function(result) { 290 | var sprite; 291 | 292 | result.path = makeSpriteSheetPath(options.spriteSheetName, result.group); 293 | 294 | sprite = new File({ 295 | path: result.path, 296 | contents: new Buffer(result.image, 'binary') 297 | }); 298 | 299 | stream.push(sprite); 300 | 301 | options.verbose && log('Spritesheet', result.path, 'has been created'); 302 | 303 | 304 | return result; 305 | }); 306 | 307 | return results; 308 | } 309 | } 310 | })(); 311 | 312 | var exportStylesheet = function(stream, options) { 313 | return function(content) { 314 | var stylesheet; 315 | 316 | stylesheet = new File({ 317 | path: options.styleSheetName, 318 | contents: new Buffer(content) 319 | }); 320 | 321 | stream.push(stylesheet); 322 | 323 | options.verbose && log('Stylesheet', options.styleSheetName, 'has been created'); 324 | } 325 | }; 326 | 327 | var mapSpritesProperties = function(images, options) { 328 | return function(results) { 329 | return results.map(function(result) { 330 | return _.map(result.coordinates, function(coordinates, path) { 331 | return _.merge(_.find(images, {path: path}), { 332 | coordinates: coordinates, 333 | spriteSheetPath: options.spriteSheetPath ? options.spriteSheetPath + "/" + result.path : result.path, 334 | properties: result.properties 335 | }); 336 | }); 337 | }); 338 | } 339 | }; 340 | 341 | module.exports = function(options) { 'use strict'; 342 | var stream, styleSheetStream, spriteSheetStream; 343 | 344 | debug = { 345 | sprites: 0, 346 | images: 0 347 | }; 348 | 349 | options = _.merge({ 350 | src: [], 351 | engine: "pngsmith", //auto 352 | algorithm: "top-down", 353 | padding: 0, 354 | engineOpts: {}, 355 | exportOpts: { 356 | 357 | }, 358 | imgOpts: { 359 | timeout: 30000 360 | }, 361 | 362 | baseUrl: './', 363 | retina: true, 364 | styleSheetName: null, 365 | spriteSheetName: null, 366 | spriteSheetPath: null, 367 | filter: [], 368 | groupBy: [], 369 | accumulate: false, 370 | verbose: false 371 | }, options || {}); 372 | 373 | // check necessary properties 374 | ['spriteSheetName'].forEach(function(property) { 375 | if (!options[property]) { 376 | throw new gutil.PluginError(PLUGIN_NAME, '`' + property + '` is required'); 377 | } 378 | }); 379 | 380 | // prepare filters 381 | if (_.isFunction(options.filter)) { 382 | options.filter = [options.filter] 383 | } 384 | 385 | // prepare groupers 386 | if (_.isFunction(options.groupBy)) { 387 | options.groupBy = [options.groupBy] 388 | } 389 | 390 | // add meta skip filter 391 | options.filter.unshift(function(image) { 392 | image.meta.skip && options.verbose && log(image.path + ' has been skipped as it meta declares to skip'); 393 | return !image.meta.skip; 394 | }); 395 | 396 | // add not existing filter 397 | options.filter.push(function(image) { 398 | var deferred = Q.defer(); 399 | 400 | fs.exists(image.path, function(exists) { 401 | !exists && options.verbose && log(image.path + ' has been skipped as it does not exist!'); 402 | deferred.resolve(exists); 403 | }); 404 | 405 | return deferred.promise; 406 | }); 407 | 408 | // add retina grouper if needed 409 | if (options.retina) { 410 | options.groupBy.unshift(function(image) { 411 | if (image.isRetina) { 412 | return "@" + image.retinaRatio + "x"; 413 | } 414 | 415 | return null; 416 | }); 417 | } 418 | 419 | // create output streams 420 | function noop(){} 421 | styleSheetStream = new Readable({objectMode: true}); 422 | spriteSheetStream = new Readable({objectMode: true}); 423 | spriteSheetStream._read = styleSheetStream._read = noop; 424 | 425 | var accumulatedFiles = []; 426 | 427 | stream = through.obj( 428 | function(file, enc, done) { 429 | if (file.isNull()) { 430 | this.push(file); // Do nothing if no contents 431 | return done(); 432 | } 433 | 434 | if (file.isStream()) { 435 | this.emit('error', new gutil.PluginError(PLUGIN_NAME, 'Streams is not supported!')); 436 | return done(); 437 | } 438 | 439 | if (file.isBuffer()) { 440 | // postpone evaluation, if we accumulating 441 | if (options.accumulate) { 442 | accumulatedFiles.push(file); 443 | stream.push(file); 444 | done(); 445 | return; 446 | } 447 | 448 | getImages(file, options) 449 | .then(function(images) { 450 | callSpriteSmithWith(images, options) 451 | .then(exportSprites(spriteSheetStream, options)) 452 | .then(mapSpritesProperties(images, options)) 453 | .then(updateReferencesIn(file)) 454 | .then(exportStylesheet(styleSheetStream, _.extend({}, options, { styleSheetName: options.styleSheetName || path.basename(file.path) }))) 455 | .then(function() { 456 | // pipe source file 457 | stream.push(file); 458 | done(); 459 | }) 460 | .catch(function(err) { 461 | stream.emit('error', new gutil.PluginError(PLUGIN_NAME, err)); 462 | done(); 463 | }); 464 | }); 465 | 466 | 467 | return null; 468 | } else { 469 | this.emit('error', new gutil.PluginError(PLUGIN_NAME, 'Something went wrong!')); 470 | return done(); 471 | } 472 | }, 473 | // flush 474 | function(done) { 475 | var pending; 476 | 477 | if (options.accumulate) { 478 | pending = Q 479 | .all(accumulatedFiles.map(function(file) { 480 | return getImages(file, options); 481 | })) 482 | .then(function(list) { 483 | var images; 484 | 485 | return _.chain(list) 486 | .reduce(function(images, portion) { 487 | return images.concat(portion); 488 | }, []) 489 | .unique(function(image) { 490 | return image.path; 491 | }) 492 | .value(); 493 | }) 494 | .then(function(images) { 495 | return callSpriteSmithWith(images, options) 496 | .then(exportSprites(spriteSheetStream, options)) 497 | .then(mapSpritesProperties(images, options)) 498 | .then(function(results) { 499 | return Q.all(accumulatedFiles.map(function(file) { 500 | return updateReferencesIn(file)(results) 501 | .then(exportStylesheet(styleSheetStream, _.extend({}, options, { styleSheetName: path.basename(file.path) }))); 502 | })); 503 | }); 504 | }) 505 | .catch(function(err) { 506 | stream.emit('error', new gutil.PluginError(PLUGIN_NAME, err)); 507 | done(); 508 | }); 509 | } else { 510 | pending = Q(); 511 | } 512 | 513 | pending.then(function() { 514 | // end streams 515 | styleSheetStream.push(null); 516 | spriteSheetStream.push(null); 517 | 518 | log(util.format("Created %d sprite(s) from %d images, saved %s% requests", debug.sprites, debug.images, debug.images > 0 ? ((debug.sprites / debug.images) * 100).toFixed(1) : 0)); 519 | 520 | done(); 521 | }); 522 | } 523 | ); 524 | 525 | stream.css = styleSheetStream; 526 | stream.img = spriteSheetStream; 527 | 528 | return stream; 529 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-sprite-generator", 3 | "version": "0.2.3", 4 | "description": "Plugin that generate sprites from your stylesheets (using spritesmith) and then updates the references.", 5 | "main": "index.js", 6 | "test": "mocha test", 7 | "keywords": [ 8 | "gulpplugin", 9 | "spritesheet", 10 | "sprite", 11 | "generator", 12 | "png", 13 | "stylesheet", 14 | "spritesmith", 15 | "css" 16 | ], 17 | "author": { 18 | "name": "Sergey Kamardin", 19 | "email": "gobwas@gmail.com", 20 | "url": "http://github.com/gobwas" 21 | }, 22 | "license": "MIT", 23 | "repository": "https://github.com/gobwas/gulp-sprite-generator", 24 | "devDependencies": { 25 | "mocha": "*", 26 | "chai": "*", 27 | "gulp": "*" 28 | }, 29 | "scripts": { 30 | "test": "mocha ./test" 31 | }, 32 | "dependencies": { 33 | "async": "^0.2.10", 34 | "colors": "^0.6.2", 35 | "gulp-util": "^2.2.14", 36 | "lodash": "^2.4.1", 37 | "q": "^1.0.1", 38 | "spritesmith": "^0.18.0", 39 | "through2": "^0.4.1", 40 | "vinyl": "^0.2.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/expectations/A.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url("sprite.png"); 3 | background-position: -0px -0px; 4 | background-size: 25px 50px!important; 5 | } -------------------------------------------------------------------------------- /test/expectations/B.css: -------------------------------------------------------------------------------- 1 | .b { 2 | background-image: url("sprite.png"); 3 | background-position: -0px -25px; 4 | background-size: 25px 50px!important; 5 | } -------------------------------------------------------------------------------- /test/expectations/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gobwas/gulp-sprite-generator/3b903fd6df84e5d582017b329aededf78ea7d6f2/test/expectations/sprite.png -------------------------------------------------------------------------------- /test/expectations/stylesheet.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url("sprite.png"); 3 | background-position: -0px -0px; 4 | background-size: 25px 50px!important; 5 | } 6 | .b { 7 | background-image: url("sprite.png"); 8 | background-position: -0px -25px; 9 | background-size: 25px 50px!important; 10 | } 11 | .c { 12 | background-image: url("sprite.png"); 13 | background-position: -0px -0px; 14 | background-size: 25px 50px!important; 15 | } -------------------------------------------------------------------------------- /test/expectations/stylesheet.filter.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url(/a.png); 3 | } 4 | .b { 5 | background-image: url("sprite.png"); 6 | background-position: -0px -0px; 7 | background-size: 25px 25px!important; 8 | } 9 | .c { 10 | background-image: url("/a.png"); 11 | } -------------------------------------------------------------------------------- /test/expectations/stylesheet.groupby.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url("sprite.my.png"); 3 | background-position: -0px -0px; 4 | background-size: 25px 50px!important; 5 | } 6 | .b { 7 | background-image: url("sprite.my.png"); 8 | background-position: -0px -25px; 9 | background-size: 25px 50px!important; 10 | } 11 | .c { 12 | background-image: url("sprite.my.png"); 13 | background-position: -0px -0px; 14 | background-size: 25px 50px!important; 15 | } -------------------------------------------------------------------------------- /test/expectations/stylesheet.retina.css: -------------------------------------------------------------------------------- 1 | @media (min-resolution: 2dppx) { 2 | .a { 3 | background-image: url("sprite.@2x.png"); 4 | background-position: -0px -0px; 5 | background-size: 25px 50px!important; 6 | background-size: 25px 25px; 7 | } 8 | 9 | .b { 10 | background-image: url("sprite.@2x.png"); 11 | background-position: -0px -25px; 12 | background-size: 25px 50px!important; 13 | background-size: 25px 25px; 14 | } 15 | } -------------------------------------------------------------------------------- /test/fixtures/A.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url(/a.png); 3 | } -------------------------------------------------------------------------------- /test/fixtures/B.css: -------------------------------------------------------------------------------- 1 | .b { 2 | background-image: url(/b.png); 3 | } -------------------------------------------------------------------------------- /test/fixtures/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gobwas/gulp-sprite-generator/3b903fd6df84e5d582017b329aededf78ea7d6f2/test/fixtures/a.png -------------------------------------------------------------------------------- /test/fixtures/a@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gobwas/gulp-sprite-generator/3b903fd6df84e5d582017b329aededf78ea7d6f2/test/fixtures/a@2x.png -------------------------------------------------------------------------------- /test/fixtures/b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gobwas/gulp-sprite-generator/3b903fd6df84e5d582017b329aededf78ea7d6f2/test/fixtures/b.png -------------------------------------------------------------------------------- /test/fixtures/b@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gobwas/gulp-sprite-generator/3b903fd6df84e5d582017b329aededf78ea7d6f2/test/fixtures/b@2x.png -------------------------------------------------------------------------------- /test/fixtures/stylesheet.css: -------------------------------------------------------------------------------- 1 | .a { 2 | background-image: url(/a.png); 3 | } 4 | .b { 5 | background-image: url("/b.png"); 6 | } 7 | .c { 8 | background-image: url("/a.png"); 9 | } -------------------------------------------------------------------------------- /test/fixtures/stylesheet.retina.css: -------------------------------------------------------------------------------- 1 | @media (min-resolution: 2dppx) { 2 | .a { 3 | background-image: url("/a@2x.png"); 4 | background-size: 25px 25px; 5 | } 6 | 7 | .b { 8 | background-image: url("/b@2x.png"); 9 | background-size: 25px 25px; 10 | } 11 | } -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | assert = require('chai').assert, 3 | File = require('vinyl'), 4 | path = require('path'), 5 | through = require('through2'), 6 | // gulpif = require("gulp-if"), 7 | // rev = require("gulp-rev"), 8 | sprite = require('./../index'); 9 | 10 | 11 | function clearStr(str) { 12 | return str.replace(/[\s,\r,\n,\t]/gi, ""); 13 | } 14 | 15 | describe('gulp-sprite-generator', function(){ 16 | 17 | var test, fixtures, expectations; 18 | 19 | before(function() { 20 | test = path.resolve(__dirname, '.'); 21 | fixtures = path.resolve(test, 'fixtures'); 22 | expectations = path.resolve(test, 'expectations'); 23 | }); 24 | 25 | it("should accumulate images and create common sprite from multiple stylesheets", function(done) { 26 | var config, stream, errors, stylesheet, index; 27 | 28 | index = ['A.css', 'B.css']; 29 | 30 | stylesheet = { 31 | fixtures: [path.resolve(fixtures, 'A.css'), path.resolve(fixtures, 'B.css')], 32 | expectations: [path.resolve(expectations, 'A.css'), path.resolve(expectations, 'B.css')], 33 | }; 34 | 35 | errors = []; 36 | 37 | config = { 38 | src: [], 39 | engine: "auto", 40 | algorithm: "top-down", 41 | padding: 0, 42 | engineOpts: {}, 43 | exportOpts: {}, 44 | 45 | baseUrl: fixtures, 46 | spriteSheetName: "sprite.png", 47 | spriteSheetPath: null, 48 | filter: [], 49 | groupBy: [], 50 | accumulate: true 51 | }; 52 | 53 | stream = sprite(config); 54 | 55 | stream.img.on('data', function (file) { 56 | try { 57 | assert.equal(file.path, config.spriteSheetName); 58 | } catch (err) { 59 | errors.push(err); 60 | } 61 | }); 62 | 63 | stream.css.on('data', function (file) { 64 | var id; 65 | 66 | id = index.indexOf(file.path); 67 | 68 | try { 69 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectations[id]).toString())); 70 | } catch (err) { 71 | errors.push(err); 72 | } 73 | }); 74 | 75 | stream.write(new File({ 76 | base: test, 77 | path: stylesheet.fixtures[0], 78 | contents: new Buffer(fs.readFileSync(stylesheet.fixtures[0])) 79 | })); 80 | 81 | stream.write(new File({ 82 | base: test, 83 | path: stylesheet.fixtures[1], 84 | contents: new Buffer(fs.readFileSync(stylesheet.fixtures[1])) 85 | })); 86 | 87 | stream.on('finish', function() { 88 | done(errors[0]); 89 | }); 90 | 91 | stream.end(); 92 | }); 93 | 94 | it("Should create sprite and change refs in stylesheet", function(done) { 95 | var config, stream, errors, stylesheet; 96 | 97 | stylesheet = { 98 | fixture: path.resolve(fixtures, 'stylesheet.css'), 99 | expectation: path.resolve(expectations, 'stylesheet.css') 100 | }; 101 | 102 | errors = []; 103 | 104 | config = { 105 | src: [], 106 | engine: "auto", 107 | algorithm: "top-down", 108 | padding: 0, 109 | engineOpts: {}, 110 | exportOpts: {}, 111 | 112 | baseUrl: fixtures, 113 | spriteSheetName: "sprite.png", 114 | styleSheetName: "stylesheet.sprite.css", 115 | spriteSheetPath: null, 116 | filter: [], 117 | groupBy: [] 118 | }; 119 | 120 | stream = sprite(config); 121 | 122 | stream.img.on('data', function (file) { 123 | try { 124 | assert.equal(file.path, config.spriteSheetName); 125 | } catch (err) { 126 | errors.push(err); 127 | } 128 | }); 129 | 130 | stream.css.on('data', function (file) { 131 | try { 132 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectation).toString())); 133 | assert.equal(file.path, config.styleSheetName); 134 | } catch (err) { 135 | errors.push(err); 136 | } 137 | }); 138 | 139 | stream.write(new File({ 140 | base: test, 141 | path: stylesheet.fixture, 142 | contents: new Buffer(fs.readFileSync(stylesheet.fixture)) 143 | })); 144 | 145 | stream.on('finish', function() { 146 | done(errors[0]); 147 | }); 148 | 149 | stream.end(); 150 | }); 151 | 152 | it("Should create sprite for retina and change refs in stylesheet", function(done) { 153 | var config, stream, errors, 154 | stylesheet; 155 | 156 | stylesheet = { 157 | fixture: path.resolve(fixtures, 'stylesheet.retina.css'), 158 | expectation: path.resolve(expectations, 'stylesheet.retina.css') 159 | }; 160 | 161 | errors = []; 162 | 163 | config = { 164 | src: [], 165 | engine: "auto", 166 | algorithm: "top-down", 167 | padding: 0, 168 | engineOpts: {}, 169 | exportOpts: {}, 170 | 171 | baseUrl: fixtures, 172 | spriteSheetName: "sprite.png", 173 | styleSheetName: "stylesheet.sprite.css", 174 | spriteSheetPath: null, 175 | filter: [], 176 | groupBy: [] 177 | }; 178 | 179 | stream = sprite(config); 180 | 181 | stream.img.on('data', function (file) { 182 | try { 183 | assert.equal(file.path, 'sprite.@2x.png'); 184 | } catch (err) { 185 | errors.push(err); 186 | } 187 | }); 188 | 189 | stream.css.on('data', function (file) { 190 | try { 191 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectation).toString())); 192 | assert.equal(file.path, config.styleSheetName); 193 | } catch (err) { 194 | errors.push(err); 195 | } 196 | }); 197 | 198 | stream.write(new File({ 199 | base: test, 200 | path: stylesheet.fixture, 201 | contents: new Buffer(fs.readFileSync(stylesheet.fixture)) 202 | })); 203 | 204 | stream.on('finish', function() { 205 | done(errors[0]); 206 | }); 207 | 208 | stream.end(); 209 | }); 210 | 211 | it("Should create sprite using groupBy and change refs in stylesheet", function(done) { 212 | var config, stream, errors, 213 | stylesheet; 214 | 215 | stylesheet = { 216 | fixture: path.resolve(fixtures, 'stylesheet.css'), 217 | expectation: path.resolve(expectations, 'stylesheet.groupby.css') 218 | }; 219 | 220 | errors = []; 221 | 222 | config = { 223 | src: [], 224 | engine: "auto", 225 | algorithm: "top-down", 226 | padding: 0, 227 | engineOpts: {}, 228 | exportOpts: {}, 229 | 230 | baseUrl: fixtures, 231 | spriteSheetName: "sprite.png", 232 | styleSheetName: "stylesheet.sprite.css", 233 | spriteSheetPath: null, 234 | filter: [], 235 | groupBy: [] 236 | }; 237 | 238 | config.groupBy.push(function(image) { 239 | return "my"; 240 | }); 241 | 242 | stream = sprite(config); 243 | 244 | stream.img.on('data', function (file) { 245 | try { 246 | assert.equal(file.path, 'sprite.my.png'); 247 | } catch (err) { 248 | errors.push(err); 249 | } 250 | }); 251 | 252 | stream.css.on('data', function (file) { 253 | try { 254 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectation).toString())); 255 | assert.equal(file.path, config.styleSheetName); 256 | } catch (err) { 257 | errors.push(err); 258 | } 259 | }); 260 | 261 | stream.write(new File({ 262 | base: test, 263 | path: stylesheet.fixture, 264 | contents: new Buffer(fs.readFileSync(stylesheet.fixture)) 265 | })); 266 | 267 | stream.on('finish', function() { 268 | done(errors[0]); 269 | }); 270 | 271 | stream.end(); 272 | }); 273 | 274 | it("Should create sprite using filter and change refs in stylesheet", function(done) { 275 | var config, stream, errors, 276 | stylesheet; 277 | 278 | stylesheet = { 279 | fixture: path.resolve(fixtures, 'stylesheet.css'), 280 | expectation: path.resolve(expectations, 'stylesheet.filter.css') 281 | }; 282 | 283 | errors = []; 284 | 285 | config = { 286 | src: [], 287 | engine: "auto", 288 | algorithm: "top-down", 289 | padding: 0, 290 | engineOpts: {}, 291 | exportOpts: {}, 292 | 293 | baseUrl: fixtures, 294 | spriteSheetName: "sprite.png", 295 | styleSheetName: "stylesheet.sprite.css", 296 | spriteSheetPath: null, 297 | filter: [], 298 | groupBy: [] 299 | }; 300 | 301 | config.filter.push(function(image) { 302 | return image.url != "/a.png"; 303 | }); 304 | 305 | stream = sprite(config); 306 | 307 | stream.img.on('data', function (file) { 308 | try { 309 | assert.equal(file.path, 'sprite.png'); 310 | } catch (err) { 311 | errors.push(err); 312 | } 313 | }); 314 | 315 | stream.css.on('data', function (file) { 316 | try { 317 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectation).toString())); 318 | assert.equal(file.path, config.styleSheetName); 319 | } catch (err) { 320 | errors.push(err); 321 | } 322 | }); 323 | 324 | stream.write(new File({ 325 | base: test, 326 | path: stylesheet.fixture, 327 | contents: new Buffer(fs.readFileSync(stylesheet.fixture)) 328 | })); 329 | 330 | stream.on('finish', function() { 331 | done(errors[0]); 332 | }); 333 | 334 | stream.end(); 335 | }); 336 | 337 | it("Should create sprite reading meta in doc block and change refs in stylesheet", function(done) { 338 | var config, stream, errors, 339 | meta; 340 | 341 | errors = []; 342 | 343 | config = { 344 | baseUrl: fixtures, 345 | spriteSheetName: "sprite.png", 346 | filter: [] 347 | }; 348 | 349 | meta = { 350 | sprite: { 351 | some: true, 352 | prop: 1, 353 | yes: "no" 354 | } 355 | }; 356 | 357 | config.filter.push(function(image) { 358 | try { 359 | assert.deepEqual(image.meta, meta.sprite); 360 | } catch (err) { 361 | errors.push(err); 362 | } 363 | }); 364 | 365 | stream = sprite(config); 366 | 367 | stream.write(new File({ 368 | base: test, 369 | path: path.resolve(fixtures, 'stylesheetdddd.css'), 370 | contents: new Buffer('.a { background-image: url("sprite.retina-2x.png"); /* @meta ' + JSON.stringify(meta) + ' */ }') 371 | })); 372 | 373 | stream.on('finish', function() { 374 | done(errors[0]); 375 | }); 376 | 377 | stream.end(); 378 | }); 379 | 380 | it("Should pipe properly", function(done) { 381 | var config, stream, errors, stylesheet, 382 | piped; 383 | 384 | piped = { 385 | img: 0, 386 | css: 0, 387 | main: 0 388 | }; 389 | 390 | stylesheet = { 391 | fixture: path.resolve(fixtures, 'stylesheet.css'), 392 | expectation: path.resolve(expectations, 'stylesheet.css') 393 | }; 394 | 395 | errors = []; 396 | 397 | config = { 398 | baseUrl: fixtures, 399 | spriteSheetName: "sprite.png", 400 | styleSheetName: "stylesheet.sprite.css", 401 | spriteSheetPath: null, 402 | filter: [], 403 | groupBy: [] 404 | }; 405 | 406 | stream = sprite(config); 407 | 408 | stream.img.on('data', function (file) { 409 | try { 410 | assert.equal(file.path, config.spriteSheetName); 411 | } catch (err) { 412 | errors.push(err); 413 | } 414 | }); 415 | 416 | stream.css.on('data', function (file) { 417 | try { 418 | assert.equal(clearStr(file.contents.toString()), clearStr(fs.readFileSync(stylesheet.expectation).toString())); 419 | assert.equal(file.path, config.styleSheetName); 420 | } catch (err) { 421 | errors.push(err); 422 | } 423 | }); 424 | 425 | stream.write(new File({ 426 | base: test, 427 | path: stylesheet.fixture, 428 | contents: new Buffer(fs.readFileSync(stylesheet.fixture)) 429 | })); 430 | 431 | 432 | stream.pipe(through.obj(function(file, enc, done) { 433 | piped.main++; 434 | try { 435 | assert.instanceOf(file, File, "Piped in a main stream obj is not a File"); 436 | } catch (err) { 437 | errors.push(err); 438 | } 439 | })); 440 | 441 | stream.css.pipe(through.obj(function(file, enc, done) { 442 | piped.css++; 443 | try { 444 | assert.instanceOf(file, File, "Piped in a css stream obj is not a File"); 445 | } catch (err) { 446 | errors.push(err); 447 | } 448 | })); 449 | 450 | stream.img.pipe(through.obj(function(file, enc, done) { 451 | piped.img++; 452 | try { 453 | assert.instanceOf(file, File, "Piped in a img stream obj is not a File"); 454 | } catch (err) { 455 | errors.push(err); 456 | } 457 | })); 458 | 459 | // stream.img 460 | // .pipe(rev()) 461 | // .pipe(through.obj(function(file, enc, done) { 462 | // console.log('revision', file.path); 463 | // this.push(file); 464 | // done(); 465 | // })) 466 | // .pipe(rev.manifest()) 467 | // .pipe(through.obj(function(file, enc, done) { 468 | // console.log('manifest', file); 469 | // this.push(file); 470 | // done(); 471 | // })); 472 | 473 | stream.on('finish', function() { 474 | try { 475 | assert.equal(1, piped.img, "No piped data in img stream"); 476 | assert.equal(1, piped.css, "No piped data in css stream"); 477 | assert.equal(1, piped.main, "No piped data in main stream"); 478 | } catch (err) { 479 | errors.push(err); 480 | } 481 | 482 | done(errors[0]); 483 | }); 484 | 485 | stream.end(); 486 | }); 487 | 488 | }); 489 | 490 | --------------------------------------------------------------------------------