├── .jshintrc ├── test ├── mocha.opts ├── .jshintrc ├── images │ ├── expected-email-20x20.png │ ├── expected-800f6893213eec77f13405f4a806b6c1-20x20.png │ └── email.svg └── test.js ├── .editorconfig ├── .travis.yml ├── .gitignore ├── phantomjs-script.js ├── package.json ├── README.md └── index.js /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | } 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --timeout 10000 2 | --slow 10000 3 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.jshintrc", 3 | "mocha": true 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "10" 5 | - "8" 6 | - "6" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /test/images/email-20x20.png 2 | /test/800f6893213eec77f13405f4a806b6c1-20x20.png 3 | /node_modules/ 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /test/images/expected-email-20x20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justim/postcss-svg-fallback/HEAD/test/images/expected-email-20x20.png -------------------------------------------------------------------------------- /test/images/expected-800f6893213eec77f13405f4a806b6c1-20x20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justim/postcss-svg-fallback/HEAD/test/images/expected-800f6893213eec77f13405f4a806b6c1-20x20.png -------------------------------------------------------------------------------- /test/images/email.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /phantomjs-script.js: -------------------------------------------------------------------------------- 1 | 2 | /* global phantom, document */ 3 | 'use strict'; 4 | 5 | var webpage = require('webpage'); 6 | var system = require('system'); 7 | 8 | var image = { 9 | image: system.args[1], 10 | size: { 11 | width: system.args[2], 12 | height: system.args[3] 13 | } 14 | }; 15 | 16 | var dest = system.args[4]; 17 | 18 | var page = require('webpage').create(); 19 | page.open(image.image, function(status) { 20 | if (status !== 'success') { 21 | console.error('Could not open file'); 22 | phantom.exit(); 23 | return; 24 | } 25 | 26 | page.viewportSize = image.size; 27 | 28 | setTimeout(function() { 29 | page.render(dest); 30 | phantom.exit(); 31 | }, 0); 32 | }); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-svg-fallback", 3 | "description": "An automatic SVG converter for your CSS files", 4 | "repository": { 5 | "type": "git", 6 | "url": "git://github.com/justim/postcss-svg-fallback" 7 | }, 8 | "version": "1.5.0", 9 | "main": "index.js", 10 | "dependencies": { 11 | "postcss": "~4.1.9 || ^5.0.0 || ^6.0.0 || ^7.0.0", 12 | "phantomjs": "~1.9.16", 13 | "async": "~0.9.0", 14 | "when": "~3.7.3" 15 | }, 16 | "devDependencies": { 17 | "mocha": "~3.2.0", 18 | "chai": "~3.5.0", 19 | "extend": "~3.0.0" 20 | }, 21 | "keywords": [ 22 | "postcss", 23 | "postcss-plugin", 24 | "svg", 25 | "svg-fallback" 26 | ], 27 | "scripts": { 28 | "test": "mocha" 29 | }, 30 | "author": { 31 | "name": "Tim", 32 | "url": "https://github.com/justim" 33 | }, 34 | "license": "MIT" 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svg-fallback [![Build Status](https://secure.travis-ci.org/justim/postcss-svg-fallback.png)](https://travis-ci.org/justim/postcss-svg-fallback) 2 | 3 | > An automatic SVG converter for your CSS files, built on top of the [PostCSS] ecosystem. 4 | 5 | ## Usage 6 | 7 | Right now it is only possible to use this as a [PostCSS] plugin: 8 | 9 | ```js 10 | var postcss = require('postcss') 11 | var svgFallback = require('postcss-svg-fallback') 12 | 13 | var input = read(/* read some css */); 14 | postcss() 15 | .use(svgFallback({ 16 | // base path for the images found in the css 17 | // this is most likely the path to the css file you're processing 18 | // not setting this option might lead to unexpected behavior 19 | basePath: '', 20 | 21 | // destination for the generated SVGs 22 | // this is most likely the path to where the generated css file is outputted 23 | // not setting this option might lead to unexpected behavior 24 | dest: '', 25 | 26 | // selector that gets prefixed to selector 27 | fallbackSelector: '.no-svg', 28 | 29 | // when `true` only the css is changed (no new files created) 30 | disableConvert: false, 31 | }) 32 | .process(input) 33 | .then(function(processor) { 34 | var output = processor.toString(); 35 | }); 36 | ``` 37 | 38 | > Note: we must use the async version of postcss 39 | 40 | Converts this: 41 | 42 | ```css 43 | .icon { 44 | background: url(images/sun-is-shining.svg) no-repeat; 45 | background-size: 20px 20px; /* background-size is mandatory */ 46 | } 47 | 48 | .icon-inline { 49 | background: url(data:image/svg+xml; .. svg data ..) no-repeat; 50 | background-size: 20px 20px; /* background-size is mandatory */ 51 | } 52 | ``` 53 | 54 | to this: 55 | 56 | ```css 57 | .icon { 58 | /* original declarations are untouched */ 59 | background: url(images/sun-is-shining.svg) no-repeat; 60 | background-size: 20px 20px; 61 | } 62 | 63 | /* same selector, but with a prefix */ 64 | .no-svg .icon { 65 | /* a png image is generated and placed in the `dest` folder, 66 | * with default settings, that's right next to the original SVG 67 | */ 68 | background-image: url(images/sun-is-shining-20x20.png); 69 | } 70 | 71 | .icon-inline { 72 | background: url(data:image/svg+xml; .. svg data ..) no-repeat; 73 | background-size: 20px 20px; /* background-size is mandatory */ 74 | } 75 | 76 | .no-svg .icon-inline { 77 | /* filename contains the hash of the svg data */ 78 | background-image: url(3547c094eaf671040650cdcab2ca70fd-20x20.png); 79 | } 80 | ``` 81 | 82 | Converting is done with [PhantomJS] and is only done for images that actually need conversion (`background-size` & `mtime`). 83 | 84 | [PostCSS]: https://github.com/postcss/postcss 85 | [PhantomJS]: http://phantomjs.org 86 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var path = require('path'); 5 | var fs = require('fs'); 6 | var crypto = require('crypto'); 7 | 8 | var postcss = require('postcss'); 9 | var async = require('async'); 10 | var when = require('when'); 11 | var phantomjs = require('phantomjs'); 12 | var childProcess = require('child_process'); 13 | 14 | var phantomjsScript = path.resolve(__dirname, './phantomjs-script.js'); 15 | 16 | var backgroundImageRegex = /url\(('|")?(([^\1]+\.svg)|(data:image\/svg\+xml[;,][^\1]+))\1\)/; 17 | var backgroundSizeRegex = /^(\d+)px( (\d+)px)?$/; 18 | 19 | function hash(input) { 20 | return crypto.createHash('md5').update(input).digest('hex'); 21 | } 22 | 23 | module.exports = postcss.plugin('postcss-svg-fallback', function(options) { 24 | var fallbackSelector; 25 | var disableConvert; 26 | options = options || {}; 27 | 28 | fallbackSelector = options.fallbackSelector || '.no-svg'; 29 | disableConvert = options.disableConvert || false; 30 | 31 | return function (css, result) { 32 | var images = []; 33 | var rulesMethodName = !!css.walkRules ? 'walkRules' : 'eachRule'; 34 | var declsMethodName = !!css.walkRules ? 'walkDecls' : 'eachDecl'; 35 | 36 | css[rulesMethodName](function(rule) { 37 | var inlineBackground; 38 | var backgroundImage; 39 | var backgroundSize; 40 | var newImage; 41 | var newRule; 42 | var newDecl; 43 | var matchedBackgroundImageDecl; 44 | var suffix; 45 | var newSelectors; 46 | 47 | // skip our added rules 48 | if (rule.selector.indexOf(fallbackSelector) !== -1) { 49 | return; 50 | } 51 | 52 | rule[declsMethodName](function(decl) { 53 | var backgroundImageMatch; 54 | var backgroundSizeMatch; 55 | 56 | if (decl.prop.match(/^background(-image)?$/)) { 57 | backgroundImageMatch = backgroundImageRegex.exec(decl.value); 58 | 59 | if (backgroundImageMatch) { 60 | matchedBackgroundImageDecl = decl; 61 | 62 | if (backgroundImageMatch[3]) { 63 | inlineBackground = false; 64 | backgroundImage = backgroundImageMatch[3]; 65 | } else { 66 | inlineBackground = true; 67 | backgroundImage = backgroundImageMatch[4]; 68 | } 69 | } 70 | } 71 | 72 | if (decl.prop === 'background-size') { 73 | backgroundSizeMatch = backgroundSizeRegex.exec(decl.value); 74 | 75 | if (backgroundSizeMatch) { 76 | backgroundSize = { 77 | width: parseInt(backgroundSizeMatch[1]), 78 | height: parseInt(backgroundSizeMatch[3] || backgroundSizeMatch[1]), 79 | }; 80 | } 81 | } 82 | }); 83 | 84 | if (backgroundImage && backgroundSize) { 85 | suffix = '-' + backgroundSize.width + 'x' + backgroundSize.height + '.png'; 86 | 87 | if (inlineBackground) { 88 | newImage = hash(backgroundImage) + suffix; 89 | } else { 90 | newImage = backgroundImage.replace(/\.svg$/, suffix); 91 | } 92 | 93 | images.push({ 94 | inline: inlineBackground, 95 | postcssResult: result, 96 | postcssRule: rule, 97 | image: backgroundImage, 98 | newImage: newImage, 99 | size: backgroundSize, 100 | }); 101 | 102 | newSelectors = rule.selectors.map(function(selector) { 103 | return fallbackSelector + ' ' + selector; 104 | }); 105 | 106 | newRule = postcss.rule({ selectors: newSelectors }); 107 | newRule.source = rule.source; 108 | 109 | newDecl = postcss.decl({ 110 | prop: 'background-image', 111 | value: 'url(' + newImage + ')', 112 | }); 113 | newDecl.source = matchedBackgroundImageDecl.source; 114 | 115 | newRule.append(newDecl); 116 | rule.parent.insertAfter(rule, newRule); 117 | } 118 | }); 119 | 120 | if (disableConvert) { 121 | return when.resolve(); 122 | } 123 | 124 | return when.promise(function(resolve, reject) { 125 | async.eachSeries(images, processImage.bind(null, options), function(err) { 126 | if (err) { 127 | reject(err); 128 | } else { 129 | resolve(); 130 | } 131 | }); 132 | }); 133 | }; 134 | }); 135 | 136 | function processImage(options, image, cb) { 137 | var source = path.join(options.basePath || '', image.image); 138 | var dest = path.join(options.dest || '', image.newImage); 139 | 140 | var args = [ 141 | phantomjsScript, 142 | image.inline ? image.image : source, 143 | image.size.width, 144 | image.size.height, 145 | dest, 146 | ]; 147 | 148 | if (image.inline) { 149 | statDest('inline', dest, args, cb); 150 | } else { 151 | fs.stat(source, function(sourceErr, sourceStat) { 152 | if (sourceStat) { 153 | statDest(sourceStat, dest, args, cb); 154 | } else { 155 | image.postcssResult.warn( 156 | 'Could not find "' + image.image + '" at "' + source + '"', 157 | { node: image.postcssRule }); 158 | 159 | cb(); 160 | } 161 | }); 162 | } 163 | } 164 | 165 | function statDest(sourceStat, dest, args, cb) { 166 | fs.stat(dest, function(destErr, destStat) { 167 | if (!destStat || !sourceStat || sourceStat !== 'inline' || sourceStat.mtime > destStat.mtime) { 168 | runPhantomJs(args, cb); 169 | } else { 170 | cb(); 171 | } 172 | }); 173 | } 174 | 175 | function runPhantomJs(args, cb) { 176 | childProcess.execFile(phantomjs.path, args, function(err, stdout, stderr) { 177 | if (err) { 178 | cb(err); 179 | } else if (stdout.length) { 180 | cb(stdout.toString().trim()); 181 | } else if (stderr.length) { 182 | cb(stderr.toString().trim()); 183 | } else { 184 | cb(); 185 | } 186 | }); 187 | } 188 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 2 | /* global describe, beforeEach, it */ 3 | 'use strict'; 4 | 5 | var fs = require('fs'); 6 | 7 | var expect = require('chai').expect; 8 | var extend = require('extend'); 9 | 10 | var postcss = require('postcss'); 11 | var svgFallback = require('../index.js'); 12 | 13 | 14 | function transform(input, extraOptions) { 15 | var options = { 16 | basePath: 'test', 17 | dest: 'test', 18 | }; 19 | 20 | if (extraOptions) { 21 | extend(true, options, extraOptions); 22 | } 23 | 24 | return postcss() 25 | .use(svgFallback(options)) 26 | .process(input); 27 | } 28 | 29 | describe('svg-fallback', function() { 30 | 31 | describe('successful-file', function() { 32 | var inputCss = '.icon {\n' + 33 | ' background: url(images/email.svg) no-repeat;\n' + 34 | ' background-size: 20px 20px;\n' + 35 | '}'; 36 | 37 | // we expect the same input as output, plus an extra rule 38 | var expectedCssOutput = inputCss + '\n' + 39 | '.no-svg .icon {\n' + 40 | ' background-image: url(images/email-20x20.png);\n' + 41 | '}'; 42 | 43 | var expectedImage = __dirname + '/images/expected-email-20x20.png'; 44 | var generatedImagePath = __dirname + '/images/email-20x20.png'; 45 | 46 | // clean up side effects 47 | beforeEach(function(done) { 48 | fs.unlink(generatedImagePath, function() { 49 | done(); 50 | }); 51 | }); 52 | 53 | it('should convert css to include the newly added rule', function(done) { 54 | transform(inputCss).then(function(result) { 55 | expect(result.css).to.equal(expectedCssOutput); 56 | 57 | done(); 58 | }).catch(done); 59 | }); 60 | 61 | it('should create a correct png file as a side effect', function(done) { 62 | transform(inputCss).then(function() { 63 | fs.readFile(generatedImagePath, function(generatedImageError, actualContents) { 64 | if (!generatedImageError) { 65 | fs.readFile(expectedImage, function(expectedImageError, expectedContents) { 66 | if (expectedImageError) { 67 | done(expectedImageError); 68 | } else if (actualContents.compare(expectedContents) !== 0) { 69 | done(new Error('png contents are not the same as expected')); 70 | } else { 71 | done(); 72 | } 73 | }); 74 | } else { 75 | done(generatedImageError); 76 | } 77 | }); 78 | }); 79 | }); 80 | 81 | it ('should change the css without create new files (when option set)', function(done) { 82 | var options = { 83 | disableConvert: true, 84 | }; 85 | 86 | transform(inputCss, options).then(function(result) { 87 | expect(result.css).to.equal(expectedCssOutput); 88 | 89 | fs.stat(generatedImagePath, function(generatedImageError) { 90 | if (generatedImageError) { 91 | done(); 92 | } else { 93 | done(new Error('file was created when expected not to')); 94 | } 95 | }); 96 | }).catch(done); 97 | }); 98 | 99 | }); 100 | 101 | describe('successful-inline', function() { 102 | // same image as `test/images/email.svg`, but with removed new lines 103 | var inputCss = '.icon {\n' + 104 | ' background: url(data:image/svg+xml;utf8,) no-repeat;\n' + 105 | ' background-size: 20px 20px;\n' + 106 | '}'; 107 | 108 | // we expect the same input as output, plus an extra rule 109 | var expectedCssOutput = inputCss + '\n' + 110 | '.no-svg .icon {\n' + 111 | ' background-image: url(800f6893213eec77f13405f4a806b6c1-20x20.png);\n' + 112 | '}'; 113 | 114 | var expectedImage = __dirname + '/images/expected-800f6893213eec77f13405f4a806b6c1-20x20.png'; 115 | var generatedImagePath = __dirname + '/800f6893213eec77f13405f4a806b6c1-20x20.png'; 116 | 117 | // clean up side effects 118 | beforeEach(function(done) { 119 | fs.unlink(generatedImagePath, function() { 120 | done(); 121 | }); 122 | }); 123 | 124 | it('should convert css to include the newly added rule', function(done) { 125 | transform(inputCss).then(function(result) { 126 | expect(result.css).to.equal(expectedCssOutput); 127 | 128 | done(); 129 | }).catch(done); 130 | }); 131 | 132 | it('should create a correct png file as a side effect', function(done) { 133 | transform(inputCss).then(function() { 134 | fs.readFile(generatedImagePath, function(generatedImageError, actualContents) { 135 | if (!generatedImageError) { 136 | fs.readFile(expectedImage, function(expectedImageError, expectedContents) { 137 | if (expectedImageError) { 138 | done(expectedImageError); 139 | } else if (actualContents.compare(expectedContents) !== 0) { 140 | done(new Error('png contents are not the same as expected')); 141 | } else { 142 | done(); 143 | } 144 | }); 145 | } else { 146 | done(generatedImageError); 147 | } 148 | }); 149 | }); 150 | }); 151 | 152 | it('should not rewrite a file if hashes are equals', function(done) { 153 | transform(inputCss).then(function() { 154 | fs.stat(generatedImagePath, function(generatedImageError, generatedStatsFirst) { 155 | if (!generatedImageError) { 156 | transform(inputCss).then(function() { 157 | fs.stat(generatedImagePath, function(generatedImageError, generatedStatsSecond) { 158 | if (!generatedImageError) { 159 | expect(generatedStatsSecond.mtime.getTime()).to.eql(generatedStatsFirst.mtime.getTime()); 160 | done(); 161 | } else { 162 | done(generatedImageError); 163 | } 164 | }); 165 | }); 166 | } else { 167 | done(generatedImageError); 168 | } 169 | }); 170 | }); 171 | }); 172 | 173 | }); 174 | 175 | describe('multiple-selector', function() { 176 | var inputCss = '.icon, .icon-2 {\n' + 177 | ' background: url(images/email.svg) no-repeat;\n' + 178 | ' background-size: 20px 20px;\n' + 179 | '}'; 180 | 181 | // we expect the same input as output, plus an extra rule 182 | var expectedCssOutput = inputCss + '\n' + 183 | '.no-svg .icon, .no-svg .icon-2 {\n' + 184 | ' background-image: url(images/email-20x20.png);\n' + 185 | '}'; 186 | var generatedImagePath = __dirname + '/images/email-20x20.png'; 187 | 188 | // clean up side effects 189 | beforeEach(function(done) { 190 | fs.unlink(generatedImagePath, function() { 191 | done(); 192 | }); 193 | }); 194 | 195 | it('should add prefix to each selector', function(done) { 196 | transform(inputCss).then(function(result) { 197 | expect(result.css).to.equal(expectedCssOutput); 198 | 199 | done(); 200 | }).catch(done); 201 | }); 202 | 203 | }); 204 | 205 | describe('warnings', function() { 206 | it ('should emit one warning when file is not found', function(done) { 207 | var input = '.icon {\n' + 208 | ' background: url(images/non-existent.svg) no-repeat;\n' + 209 | ' background-size: 20px 20px;\n' + 210 | '}'; 211 | 212 | transform(input).then(function(result) { 213 | var totalWarnings = result.warnings().length; 214 | 215 | if (totalWarnings === 1) { 216 | done(); 217 | } else if (totalWarnings === 0) { 218 | done(new Error('no warnings were emitted')); 219 | } else { 220 | done(new Error('too many warnings were emitted: ' + totalWarnings)); 221 | } 222 | }).catch(done); 223 | }); 224 | 225 | }); 226 | 227 | }); 228 | --------------------------------------------------------------------------------