svg-spritemap-webpack-plugin
Version:
Generates symbol-based SVG spritemap from all .svg files in a directory
324 lines (265 loc) • 11.3 kB
JavaScript
const path = require('path');
const svgo = require('svgo');
const xmldom = require('@xmldom/xmldom');
const svgElementAttributes = require('svg-element-attributes');
const { omit, concat, uniqBy } = require('lodash');
const { merge } = require('webpack-merge');
const { VAR_NAMESPACE, VAR_NAMESPACE_VALUE, hasVariables, hasVarNamespace, addVarNamespace } = require('./variable-parser');
// Helpers
const idify = require('./helpers/idify');
const calculateY = require('./helpers/calculate-y');
const generateSpritePrefix = require('./helpers/generate-sprite-prefix');
const generateSVGOConfig = require('./helpers/generate-svgo-config');
// Errors
const { SpriteParsingWarning } = require('./errors');
const validSymbolAttributes = [
...svgElementAttributes['*'],
...svgElementAttributes.svg.filter(attr => svgElementAttributes.symbol.includes(attr))
];
const validViewAttributes = [
...svgElementAttributes['*'],
...svgElementAttributes.svg.filter(attr => svgElementAttributes.view.includes(attr))
];
const validUseAttributes = [
...svgElementAttributes['*'],
...svgElementAttributes.svg.filter(attr => svgElementAttributes.use.includes(attr))
];
module.exports = (sources = [], options = {}, warnings = []) => {
options = merge({
sprite: {
prefix: '',
idify: idify,
gutter: 0,
generate: {
title: true,
symbol: true,
use: false,
view: false,
dimensions: false
}
},
output: {
svg: {
sizes: false
},
svgo: {
plugins: []
}
},
input: {
patterns: []
}
}, options);
// No point in generating when there are no files
if ( !sources.length ) {
return;
}
// Initialize DOM/XML/SVGO
const DOMParser = new xmldom.DOMParser();
const XMLSerializer = new xmldom.XMLSerializer();
const XMLDoc = new xmldom.DOMImplementation().createDocument(null, null, null);
// Create SVG element
const svg = XMLDoc.createElement('svg');
const sizes = {
width: [],
height: []
};
const formatPostfix = (value) => {
if ( typeof value === 'string' ) {
return value;
}
return '';
};
const getDocumentElement = (item) => {
try {
const sprite = DOMParser.parseFromString(item.sprite);
const documentElement = sprite.documentElement;
if (!documentElement) {
warnings.push(new SpriteParsingWarning(`Sprite '${item.id}' has no documentElement.`));
return;
}
return documentElement;
} catch(error) {
warnings.push(new SpriteParsingWarning(`Sprite '${item.id}' could not be parsed.\n${error}`));
}
}
// Add namespaces
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if ( options.sprite.generate.use ) {
svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
}
const items = sources.map((source) => {
const prefix = generateSpritePrefix(options.sprite.prefix, source.path);
const id = `${prefix}${options.sprite.idify(path.basename(source.path, path.extname(source.path)))}`;
if ( options.output.svgo === false ) {
return {
id: id,
sprite: source.content
};
}
const sprite = (() => {
if ( hasVariables(source.content) && !hasVarNamespace(source.content) ) {
return addVarNamespace(source.content);
}
return source.content;
})();
try {
const config = generateSVGOConfig(merge({}, options.output.svgo, {
path: source.path
}), [], [{
name: 'removeEmptyAttrs',
active: false // Prevent empty var:* attributes from getting removed prematurely
}, {
name: 'moveGroupAttrsToElems',
active: false // Prevent groups from getting optimized prematurely as they may contain var:* attributes
}, {
name: 'collapseGroups',
active: false // Prevent groups from getting removed prematurely as they may contain var:* attributes
}, {
name: 'removeTitle',
active: false // Prevent titles from getting removed prematurely
}]);
const output = svgo.optimize(sprite, config);
return {
prefix: prefix,
file: source.path,
id: id,
sprite: output.data
};
} catch (error) {
warnings.push(new SpriteParsingWarning(`Sprite '${id}' could not be optimized.\n${error}`));
}
}).filter(Boolean)
// Add the xmlns:var attribute when variables are found in any sprite
if ( hasVariables(items.map((item) => item.sprite).join('\n')) ) {
svg.setAttribute(`xmlns:${VAR_NAMESPACE}`, VAR_NAMESPACE_VALUE);
}
items.forEach((item) => {
if (!item.sprite.trim()) {
warnings.push(new SpriteParsingWarning(`Sprite '${item.id}' has an empty source file.`));
return;
}
const documentElement = getDocumentElement(item);
if (!documentElement) {
return;
}
// Attributes that should be transferred to output SVG
const attributes = Array.from(documentElement.attributes).reduce((attributes, attribute) => {
// Blacklist several attributes as they'll be added/removed while parsing
if ( ['viewbox', 'width', 'height', 'id', 'xmlns'].includes(attribute.name.toLowerCase()) ) {
return attributes;
}
return [...attributes, {
name: attribute.name,
value: attribute.value
}];
}, []);
// Add xmlns:* attributes to root SVG
attributes.forEach((attribute) => {
if ( !attribute.name.toLowerCase().startsWith('xmlns:') ) {
return;
}
svg.setAttribute(attribute.name, attribute.value);
});
// Get sizes
let viewbox = (documentElement.getAttribute('viewBox') || documentElement.getAttribute('viewbox')).split(' ').map((a) => parseFloat(a));
let width = parseFloat(documentElement.getAttribute('width'));
let height = parseFloat(documentElement.getAttribute('height'));
if ( viewbox.length !== 4 && ( isNaN(width) || isNaN(height) ) ) {
warnings.push(new SpriteParsingWarning(`Sprite '${item.id}' is invalid, it's lacking both a viewBox and width/height attributes.`));
return;
}
if ( viewbox.length !== 4 ) {
viewbox = [0, 0, width, height];
}
if ( isNaN(width) ) {
width = viewbox[2];
}
if ( isNaN(height) ) {
height = viewbox[3];
}
// Create symbol
if ( options.sprite.generate.symbol ) {
const symbol = XMLDoc.createElement('symbol');
// Attributes
attributes.forEach((attribute) => {
if ( !validSymbolAttributes.includes(attribute.name) ) {
return;
}
symbol.setAttribute(attribute.name, attribute.value);
});
symbol.setAttribute('id', `${item.id}${formatPostfix(options.sprite.generate.symbol)}`);
symbol.setAttribute('viewBox', viewbox.join(' '));
if ( options.sprite.generate.dimensions ) {
symbol.setAttribute('height', height.toString());
symbol.setAttribute('width', width.toString());
}
if ( options.sprite.generate.title ) {
// Make sure we don't overwrite the existing title
const hasTitle = (documentElement) => {
const titles = Array.from(documentElement.childNodes).filter((childNode) => {
return childNode.nodeName.toLowerCase() === 'title';
});
return !!titles.length;
};
// Add title to improve accessibility
if ( !hasTitle(documentElement) ) {
const title = XMLDoc.createElement('title');
title.appendChild(XMLDoc.createTextNode(item.id.replace(item.prefix, '')));
symbol.appendChild(title);
}
}
// Clone the original contents of the SVG file into the new symbol
Array.from(documentElement.childNodes).forEach((childNode) => {
symbol.appendChild(childNode);
});
svg.appendChild(symbol);
}
if ( options.sprite.generate.use ) {
// Generate <use> elements within spritemap to allow usage within CSS
const use = XMLDoc.createElement('use');
const y = calculateY(sizes.height, options.sprite.gutter);
// Attributes
attributes.forEach((attribute) => {
if ( !validUseAttributes.includes(attribute.name) ) {
return;
}
use.setAttribute(attribute.name, attribute.value);
});
use.setAttribute('xlink:href', `#${item.id}${formatPostfix(options.sprite.generate.symbol)}`);
use.setAttribute('x', '0');
use.setAttribute('y', y);
use.setAttribute('width', width.toString());
use.setAttribute('height', height.toString());
svg.appendChild(use);
}
if ( options.sprite.generate.view ) {
// Generate <view> elements within spritemap to allow usage within CSS
const view = XMLDoc.createElement('view');
const y = calculateY(sizes.height, options.sprite.gutter);
// Attributes
attributes.forEach((attribute) => {
if ( !validViewAttributes.includes(attribute.name) ) {
return;
}
view.setAttribute(attribute.name, attribute.value);
});
view.setAttribute('id', `${item.id}${formatPostfix(options.sprite.generate.view)}`);
view.setAttribute('viewBox', `0 ${Math.max(0, y - (options.sprite.gutter / 2))} ${width + (options.sprite.gutter / 2)} ${height + (options.sprite.gutter / 2)}`);
svg.appendChild(view);
}
// Update sizes
sizes.width.push(width);
sizes.height.push(height);
});
if ( options.output.svg.sizes ) {
// Add width/height to spritemap
svg.setAttribute('width', Math.max.apply(null, sizes.width).toString());
svg.setAttribute('height', (sizes.height.reduce((a, b) => a + b, 0) + ((sizes.height.length - 1) * options.sprite.gutter)).toString());
}
// Add custom attributes to spritemap
Object.entries(options.output.svg.attributes).forEach(([name, value]) => {
svg.setAttribute(name, value);
});
return XMLSerializer.serializeToString(svg);
};