├── .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 |
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 [](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 |
--------------------------------------------------------------------------------