├── .github
├── FUNDING.yml
└── workflows
│ └── test.yml
├── .gitignore
├── svg-superman.png
├── test
├── src
│ ├── namespace.svg
│ ├── square.svg
│ ├── circle.svg
│ ├── circle-with-gradient.svg
│ └── inline-svg.html
└── inline-svg.html
├── .jshintrc
├── gulpfile.js
├── package.json
├── index.js
├── README.md
└── test.js
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: w0rm
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | test/dest/**
3 | npm-debug.log
4 |
--------------------------------------------------------------------------------
/svg-superman.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/w0rm/gulp-svgstore/HEAD/svg-superman.png
--------------------------------------------------------------------------------
/test/src/namespace.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "asi": true,
3 | "boss": true,
4 | "browser": true,
5 | "node": true,
6 | "expr": true,
7 | "globals": {
8 | "define": false,
9 | "require": true,
10 | "_t": true
11 | },
12 | "indent": 2,
13 | "laxcomma": true,
14 | "maxlen": 100,
15 | "newcap": true,
16 | "strict": false,
17 | "trailing": true,
18 | "undef": true,
19 | "unused": true,
20 | "quotmark": "single"
21 | }
22 |
--------------------------------------------------------------------------------
/test/inline-svg.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | gulp-svgstore
4 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | npm-test:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: ['10', '12', '14']
16 |
17 | steps:
18 | - uses: actions/checkout@v2
19 |
20 | - uses: actions/setup-node@v1
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 |
24 | - run: |
25 | npm install
26 | npm test
27 |
--------------------------------------------------------------------------------
/test/src/square.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/test/src/circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/test/src/circle-with-gradient.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
--------------------------------------------------------------------------------
/test/src/inline-svg.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | gulp-svgstore
4 |
14 |
15 |
16 |
17 |
18 |
19 |
22 |
25 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const svgstore = require('./index')
2 | const gulp = require('gulp')
3 | const inject = require('gulp-inject')
4 |
5 | gulp.task('external', () =>
6 | gulp
7 | .src('test/src/*.svg')
8 | .pipe(svgstore())
9 | .pipe(gulp.dest('test/dest'))
10 | )
11 |
12 | gulp.task('inline', () => {
13 | function fileContents (_, file) {
14 | return file.contents.toString('utf8')
15 | }
16 |
17 | const svgs = gulp
18 | .src('test/src/*.svg')
19 | .pipe(svgstore({ inlineSvg: true }))
20 |
21 | return gulp
22 | .src('test/src/inline-svg.html')
23 | .pipe(inject(svgs, { transform: fileContents }))
24 | .pipe(gulp.dest('test/dest'))
25 | })
26 |
27 | gulp.task('build', gulp.series(['external', 'inline']))
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gulp-svgstore",
3 | "version": "9.0.0",
4 | "description": "Combine svg files into one with elements",
5 | "main": "index.js",
6 | "files": [
7 | "index.js"
8 | ],
9 | "scripts": {
10 | "test": "gulp build && mocha test.js"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git://github.com/w0rm/gulp-svgstore"
15 | },
16 | "author": {
17 | "name": "Andrey Kuzmin",
18 | "email": "unsoundscapes@gmail.com"
19 | },
20 | "license": "MIT",
21 | "bugs": {
22 | "url": "https://github.com/w0rm/gulp-svgstore/issues"
23 | },
24 | "homepage": "https://github.com/w0rm/gulp-svgstore",
25 | "dependencies": {
26 | "cheerio": "^1.0.0-rc.10",
27 | "fancy-log": "^1.3.3",
28 | "plugin-error": "^1.0.1",
29 | "vinyl": "^2.2.1"
30 | },
31 | "devDependencies": {
32 | "finalhandler": "^1.1.2",
33 | "gulp": "^4.0.2",
34 | "gulp-inject": "^5.0.5",
35 | "mocha": "^9.0.1",
36 | "puppeteer": "^10.0.0",
37 | "serve-static": "^1.14.1",
38 | "sinon": "^11.1.1"
39 | },
40 | "engines": {
41 | "node": ">=10.0"
42 | },
43 | "engineStrict": true,
44 | "keywords": [
45 | "gulpplugin",
46 | "svg",
47 | "icon",
48 | "sprite"
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const cheerio = require('cheerio')
2 | const path = require('path')
3 | const Stream = require('stream')
4 | const fancyLog = require('fancy-log')
5 | const PluginError = require('plugin-error')
6 | const Vinyl = require('vinyl')
7 |
8 | const presentationAttributes = new Set([
9 | 'style', 'alignment-baseline', 'baseline-shift', 'clip', 'clip-path',
10 | 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters',
11 | 'color-profile', 'color-rendering', 'cursor', 'd', 'direction', 'display',
12 | 'dominant-baseline', 'enable-background', 'fill', 'fill-opacity', 'fill-rule',
13 | 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size',
14 | 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant',
15 | 'font-weight', 'glyph-orientation-horizontal', 'glyph-orientation-vertical',
16 | 'image-rendering', 'kerning', 'letter-spacing', 'lighting-color',
17 | 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'overflow',
18 | 'pointer-events', 'shape-rendering', 'solid-color', 'solid-opacity',
19 | 'stop-color', 'stop-opacity', 'stroke', 'stroke-dasharray',
20 | 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
21 | 'stroke-opacity', 'stroke-width', 'text-anchor', 'text-decoration',
22 | 'text-rendering', 'transform', 'unicode-bidi', 'vector-effect', 'visibility',
23 | 'word-spacing', 'writing-mode'
24 | ]);
25 |
26 | module.exports = function (config) {
27 |
28 | config = config || {}
29 |
30 | const namespaces = {}
31 | let isEmpty = true
32 | let fileName
33 | const inlineSvg = config.inlineSvg || false
34 | const ids = {}
35 |
36 | let resultSvg = ''
37 | if (!inlineSvg) {
38 | resultSvg =
39 | '' +
40 | '' +
42 | resultSvg
43 | }
44 |
45 | const $ = cheerio.load(resultSvg, { xmlMode: true })
46 | const $combinedSvg = $('svg')
47 | const $combinedDefs = $('defs')
48 | const stream = new Stream.Transform({ objectMode: true })
49 |
50 | stream._transform = function transform (file, _, cb) {
51 |
52 | if (file.isStream()) {
53 | return cb(new PluginError('gulp-svgstore', 'Streams are not supported!'))
54 | }
55 |
56 | if (file.isNull()) return cb()
57 |
58 |
59 | const $svg = cheerio.load(file.contents.toString(), { xmlMode: true })('svg')
60 |
61 | if ($svg.length === 0) return cb()
62 |
63 | const idAttr = path.basename(file.relative, path.extname(file.relative))
64 | const viewBoxAttr = $svg.attr('viewBox')
65 | const preserveAspectRatioAttr = $svg.attr('preserveAspectRatio')
66 | const $symbol = $('')
67 |
68 | if (idAttr in ids) {
69 | return cb(new PluginError('gulp-svgstore', 'File name should be unique: ' + idAttr))
70 | }
71 |
72 | ids[idAttr] = true
73 |
74 | if (!fileName) {
75 | fileName = path.basename(file.base)
76 | if (fileName === '.' || !fileName) {
77 | fileName = 'svgstore.svg'
78 | } else {
79 | fileName = fileName.split(path.sep).shift() + '.svg'
80 | }
81 | }
82 |
83 | if (file && isEmpty) {
84 | isEmpty = false
85 | }
86 |
87 | $symbol.attr('id', idAttr)
88 | if (viewBoxAttr) {
89 | $symbol.attr('viewBox', viewBoxAttr)
90 | }
91 | if (preserveAspectRatioAttr) {
92 | $symbol.attr('preserveAspectRatio', preserveAspectRatioAttr)
93 | }
94 |
95 | const attrs = $svg[0].attribs
96 | for (let attrName in attrs) {
97 | if (attrName.match(/xmlns:.+/)) {
98 | const storedNs = namespaces[attrName]
99 | const attrNs = attrs[attrName]
100 |
101 | if (storedNs !== undefined) {
102 | if (storedNs !== attrNs) {
103 | fancyLog.info(
104 | attrName + ' namespace appeared multiple times with different value.' +
105 | ' Keeping the first one : "' + storedNs +
106 | '".\nEach namespace must be unique across files.'
107 | )
108 | }
109 | } else {
110 | for (let nsName in namespaces) {
111 | if (namespaces[nsName] === attrNs) {
112 | fancyLog.info(
113 | 'Same namespace value under different names : ' +
114 | nsName +
115 | ' and ' +
116 | attrName +
117 | '.\nKeeping both.'
118 | )
119 | }
120 | }
121 | namespaces[attrName] = attrNs;
122 | }
123 | }
124 | }
125 |
126 | const $defs = $svg.find('defs')
127 | if ($defs.length > 0) {
128 | $combinedDefs.append($defs.contents())
129 | $defs.remove()
130 | }
131 |
132 | let $groupWrap = null
133 | for (let [name, value] of Object.entries($svg.attr())) {
134 | if (!presentationAttributes.has(name)) continue;
135 | if (!$groupWrap) $groupWrap = $('')
136 | $groupWrap.attr(name, value)
137 | }
138 |
139 | if ($groupWrap) {
140 | $groupWrap.append($svg.contents())
141 | $symbol.append($groupWrap)
142 | } else {
143 | $symbol.append($svg.contents())
144 | }
145 | $combinedSvg.append($symbol)
146 | cb()
147 | }
148 |
149 | stream._flush = function flush (cb) {
150 | if (isEmpty) return cb()
151 | if ($combinedDefs.contents().length === 0) {
152 | $combinedDefs.remove()
153 | }
154 | for (let nsName in namespaces) {
155 | $combinedSvg.attr(nsName, namespaces[nsName])
156 | }
157 | const file = new Vinyl({ path: fileName, contents: Buffer.from($.xml()) })
158 | this.push(file)
159 | cb()
160 | }
161 |
162 | return stream;
163 | }
164 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | gulp-svgstore 
2 | =============
3 |
4 |
5 |
8 |
9 | Combine svg files into one with `` elements.
10 | Read more about this in [CSS Tricks article](http://css-tricks.com/svg-symbol-good-choice-icons/).
11 |
12 | If you need similar plugin for grunt,
13 | I encourage you to check [grunt-svgstore](https://github.com/FWeinb/grunt-svgstore).
14 |
15 | ### Options:
16 |
17 | The following options are set automatically based on file data:
18 |
19 | * `id` attribute of the `` element is set to the name of corresponding file;
20 | * result filename is the name of base directory of the first file.
21 |
22 | If your workflow is different, please use `gulp-rename` to rename sources or result.
23 |
24 | The only available option is:
25 |
26 | * inlineSvg — output only `'
160 | )
161 | , path: 'circle.svg'
162 | }))
163 |
164 | stream.end()
165 | })
166 |
167 | it('should emit error if files have the same name', (done) => {
168 | const stream = svgstore()
169 |
170 | stream.on('error', (error) => {
171 | assert.ok(error instanceof PluginError)
172 | assert.strictEqual(error.message, 'File name should be unique: circle')
173 | done()
174 | })
175 |
176 | stream.write(new Vinyl({ contents: Buffer.from(''), path: 'circle.svg' }))
177 | stream.write(new Vinyl({ contents: Buffer.from(''), path: 'circle.svg' }))
178 |
179 | stream.end()
180 | })
181 |
182 | it('should generate result filename based on base path of the first file', (done) => {
183 | const stream = svgstore()
184 |
185 | stream.on('data', (file) => {
186 | assert.strictEqual(file.relative, 'icons.svg')
187 | done()
188 | })
189 |
190 | stream.write(new Vinyl({
191 | contents: Buffer.from('')
192 | , path: 'src/icons/circle.svg'
193 | , base: 'src/icons'
194 | }))
195 |
196 | stream.write(new Vinyl({
197 | contents: Buffer.from('')
198 | , path: 'src2/icons2/square.svg'
199 | , base: 'src2/icons2'
200 | }))
201 |
202 | stream.end()
203 | })
204 |
205 | it('should generate svgstore.svg if base path of the 1st file is dot', (done) => {
206 | const stream = svgstore()
207 |
208 | stream.on('data', (file) => {
209 | assert.strictEqual(file.relative, 'svgstore.svg')
210 | done()
211 | })
212 |
213 | stream.write(new Vinyl({
214 | contents: Buffer.from('')
215 | , path: 'circle.svg'
216 | , base: '.'
217 | }))
218 |
219 | stream.write(new Vinyl({
220 | contents: Buffer.from('')
221 | , path: 'src2/icons2/square.svg'
222 | , base: 'src2'
223 | }))
224 |
225 | stream.end()
226 | })
227 |
228 | it('should include all namespace into final svg', (done) => {
229 | const stream = svgstore()
230 |
231 | stream.on('data', (file) => {
232 | const $resultSvg = cheerio.load(file.contents.toString(), { xmlMode: true })('svg')
233 |
234 | assert.strictEqual($resultSvg.attr('xmlns'), 'http://www.w3.org/2000/svg')
235 | assert.strictEqual($resultSvg.attr('xmlns:xlink'), 'http://www.w3.org/1999/xlink')
236 | done()
237 | })
238 |
239 | stream.write(new Vinyl({
240 | contents: Buffer.from(
241 | '' +
242 | '' +
243 | '')
244 | , path: 'rect.svg'
245 | }))
246 |
247 | stream.write(new Vinyl({
248 | contents: Buffer.from(
249 | '' +
251 | '' +
252 | '' +
253 | '' +
254 | '')
255 | , path: 'sandwich.svg'
256 | }))
257 |
258 | stream.end()
259 | })
260 |
261 | it('should not include duplicate namespaces into final svg', (done) => {
262 | const stream = svgstore({ inlineSvg: true })
263 |
264 | stream.on('data', (file) => {
265 | assert.strictEqual(
266 | '' +
267 | '',
268 | file.contents.toString()
269 | )
270 | done()
271 | })
272 |
273 | stream.write(new Vinyl({
274 | contents: Buffer.from(
275 | ''
276 | )
277 | , path: 'rect.svg'
278 | }))
279 |
280 | stream.write(new Vinyl({
281 | contents: Buffer.from(
282 | ''
283 | )
284 | , path: 'sandwich.svg'
285 | }))
286 |
287 | stream.end()
288 | })
289 |
290 | it('should transfer svg presentation attributes to a wrapping g element', (done) => {
291 | const stream = svgstore({ inlineSvg: true })
292 | const attrs = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" style="fill:#0000"';
293 |
294 | stream.on('data', (file) => {
295 | assert.strictEqual(
296 | '' +
297 | ``,
298 | file.contents.toString()
299 | )
300 | done()
301 | })
302 |
303 | stream.write(new Vinyl({
304 | contents: Buffer.from(
305 | `` +
306 | ''
307 | )
308 | , path: 'rect.svg'
309 | }))
310 |
311 | stream.end()
312 | })
313 |
314 | it('Warn about duplicate namespace value under different name', (done) => {
315 | const stream = svgstore()
316 |
317 | stream.on('data', () => {
318 | assert.strictEqual(
319 | 'Same namespace value under different names : xmlns:lk and xmlns:xlink.\n' +
320 | 'Keeping both.',
321 | fancyLog.info.getCall(0).args[0]
322 | )
323 | done()
324 | })
325 |
326 | stream.write(new Vinyl({
327 | contents: Buffer.from(
328 | '' +
329 | '' +
330 | '' +
331 | '')
332 | , path: 'rect.svg'
333 | }))
334 |
335 | stream.write(new Vinyl({
336 | contents: Buffer.from(
337 | '' +
339 | '' +
340 | '' +
341 | '' +
342 | '')
343 | , path: 'sandwich.svg'
344 | }))
345 |
346 | stream.end()
347 | })
348 |
349 | it('Strong warn about duplicate namespace name with different value', (done) => {
350 | const stream = svgstore()
351 |
352 | stream.on('data', () => {
353 | assert.strictEqual(
354 | 'xmlns:xlink namespace appeared multiple times with different value. ' +
355 | 'Keeping the first one : "http://www.w3.org/1998/xlink".\n' +
356 | 'Each namespace must be unique across files.'
357 | , fancyLog.info.getCall(0).args[0]
358 | )
359 | done()
360 | })
361 |
362 | stream.write(new Vinyl({
363 | contents: Buffer.from(
364 | '' +
365 | '' +
366 | '' +
367 | '')
368 | , path: 'rect.svg'
369 | }))
370 |
371 | stream.write(new Vinyl({
372 | contents: Buffer.from(
373 | '' +
375 | '' +
376 | '' +
377 | '' +
378 | '')
379 | , path: 'sandwich.svg'
380 | }))
381 |
382 | stream.end()
383 | })
384 | })
385 |
--------------------------------------------------------------------------------