postcss-sprites
Version:
Generate spritesheets from stylesheets
604 lines (517 loc) • 13.7 kB
JavaScript
import path from 'path';
import fs from 'fs-extra';
import Promise from 'bluebird';
import _ from 'lodash';
import debug from 'debug';
import RasterFactory from './factories/raster';
import VectorFactory from './factories/vector';
/**
* Wrap with promises.
*/
Promise.promisifyAll(fs);
/**
* Plugin constants.
*/
const RELATIVE_TO_FILE = 'file';
const RELATIVE_TO_RULE = 'rule';
const BACKGROUND = 'background';
const BACKGROUND_IMAGE = 'background-image';
const ONE_SPACE = ' ';
const COMMENT_TOKEN_PREFIX = '@replace|';
const GROUP_DELIMITER = '.';
const GROUP_MASK = '*';
const TYPE_RASTER = 'raster';
const TYPE_VECTOR = 'vector';
/**
* Plugin defaults.
*/
export const defaults = {
basePath: './',
stylesheetPath: null,
spritePath: './',
relativeTo: 'file',
filterBy: [],
groupBy: [],
retina: false,
hooks: {
onSaveSpritesheet: null,
onUpdateRule: null
},
spritesmith: {
engine: 'pixelsmith',
algorithm: 'binary-tree',
padding: 0,
engineOpts: {},
exportOpts: {}
},
svgsprite: {
mode: {
css: {
dimensions: true,
bust: false,
render: {
css: true
}
}
},
shape: {
id: {
generator(name, file) {
return new Buffer(file.path).toString('base64');
}
}
},
svg: {
precision: 5
}
},
verbose: false
};
/**
* Prepares the filter functions.
* @param {Object} opts
* @param {Result} result
* @return
*/
export function prepareFilterBy(opts, result) {
if (_.isFunction(opts.filterBy)) {
opts.filterBy = [opts.filterBy];
}
// Filter non existing images
opts.filterBy.unshift(image => {
return fs.statAsync(image.path)
.catch(() => {
const message = `Skip ${image.url} because doesn't exist.`;
opts.logger(message);
throw new Error(message);
});
});
}
/**
* Prepares the group functions.
* @param {Object} opts
* @return
*/
export function prepareGroupBy(opts) {
if (_.isFunction(opts.groupBy)) {
opts.groupBy = [opts.groupBy];
}
// Group by retina ratio
if (opts.retina) {
opts.groupBy.unshift((image) => {
if (image.retina) {
return Promise.resolve(`@${image.ratio}x`);
}
return Promise.reject(new Error('Not a retina image.'));
});
}
// Group by type - 'vector' or 'raster'
opts.groupBy.unshift((image) => {
if (/^\.svg/.test(path.extname(image.path))) {
return Promise.resolve(TYPE_VECTOR);
}
return Promise.resolve(TYPE_RASTER);
});
}
/**
* Walks the given CSS string and extracts all images
* that can be converted to sprite.
* @param {Node} root
* @param {Object} opts
* @param {Result} result
* @return {Promise}
*/
export function extractImages(root, opts, result) {
let images = [];
opts.logger('Extracting the images...');
// Search for background & background image declartions
root.walkRules((rule) => {
const styleFilePath = opts.relativeTo === RELATIVE_TO_RULE ? rule.source.input.file : root.source.input.file;
const ABSOLUTE_URL = /^\//;
// The host object of found image
const image = {
path: null,
url: null,
originalUrl: null,
retina: false,
ratio: 1,
groups: [],
token: '',
styleFilePath: styleFilePath
};
// Manipulate only rules with image in them
if (hasImageInRule(rule.toString())) {
const imageUrl = getImageUrl(rule.toString());
image.originalUrl = imageUrl[0];
image.url = imageUrl[1];
if (isImageSupported(image.url)) {
// Search for retina images
if (opts.retina && isRetinaImage(image.url)) {
image.retina = true;
image.ratio = getRetinaRatio(image.url);
}
// Get the filesystem path to the image
if (ABSOLUTE_URL.test(image.url)) {
image.path = path.resolve(opts.basePath + image.url);
} else {
image.path = path.resolve(path.dirname(styleFilePath), image.url);
}
images.push(image);
} else {
opts.logger(`Skip ${image.url} because isn't supported.`)
}
}
});
// Remove duplicates and empty values
images = _.uniqBy(images, 'path');
return Promise.resolve([opts, images]);
}
/**
* Apply filterBy functions over collection of exported images.
* @param {Object} opts
* @param {Array} images
* @return {Promise}
*/
export function applyFilterBy(opts, images) {
opts.logger('Applying the filters...');
return Promise.reduce(opts.filterBy, (images, filterFn) => {
return Promise.filter(images, (image) => {
return filterFn(image)
.then(() => true)
.catch(() => false);
}, { concurrency: 1 });
}, images).then(images => [opts, images]);
}
/**
* Apply groupBy functions over collection of exported images.
* @param {Object} opts
* @param {Array} images
* @return {Promise}
*/
export function applyGroupBy(opts, images) {
opts.logger('Applying the groups...');
return Promise.reduce(opts.groupBy, (images, groupFn) => {
return Promise.map(images, (image) => {
return groupFn(image)
.then(group => {
image.groups.push(group);
return image;
})
.catch(() => image);
});
}, images).then(images => [opts, images]);
}
/**
* Replaces the background declarations that needs to be updated
* with a sprite image.
* @param {Node} root
* @param {Object} opts
* @param {Array} images
* @return {Promise}
*/
export function setTokens(root, opts, images) {
return new Promise((resolve, reject) => {
root.walkDecls(/^background(-image)?$/, (decl) => {
const rule = decl.parent;
const ruleStr = rule.toString();
let url, image, color, backgroundColorDecl, commentDecl;
if (!hasImageInRule(ruleStr)) {
return;
}
// Manipulate only rules with image in them
url = getImageUrl(ruleStr)[1];
image = _.find(images, { url });
if (!image) {
return;
}
// Remove all necessary background declarations
rule.walkDecls(/^background-(repeat|size|position)$/, decl => decl.remove());
// Extract color to background-color property
if (decl.prop === BACKGROUND) {
color = getColor(decl.value);
if (color) {
decl.cloneAfter({
prop: 'background-color',
value: color
}).raws.before = ONE_SPACE;
}
}
// Replace with comment token
if (_.includes([BACKGROUND, BACKGROUND_IMAGE], decl.prop)) {
commentDecl = decl.cloneAfter({
type: 'comment',
text: image.url
});
commentDecl.raws.left = `${ONE_SPACE}${COMMENT_TOKEN_PREFIX}`;
image.token = commentDecl.toString();
decl.remove();
}
});
resolve([root, opts, images]);
});
}
/**
* Process the images through spritesmith module.
* @param {Object} opts
* @param {Array} images
* @return {Promise}
*/
export function runSpritesmith(opts, images) {
opts.logger('Generating the spritesheets...');
return new Promise((resolve, reject) => {
const promises = _.chain(images)
.groupBy((image) => {
let tmp = image.groups.map(maskGroup(true));
tmp.unshift('_');
return tmp.join(GROUP_DELIMITER);
})
.map((images, tmp) => {
const factory = tmp.indexOf(TYPE_VECTOR) > -1 ? VectorFactory : RasterFactory;
return factory(opts, images)
.then((spritesheet) => {
// Remove the '_', 'raster' or 'vector' prefixes
tmp = tmp.split(GROUP_DELIMITER).splice(2);
spritesheet.groups = tmp.map(maskGroup());
return spritesheet;
});
})
.value();
Promise.all(promises)
.then((spritesheets) => {
resolve([opts, images, spritesheets])
})
.catch((err) => {
reject(err);
});
});
}
/**
* Saves the spritesheets to the disk.
* @param {Object} opts
* @param {Array} images
* @param {Array} spritesheets
* @return {Promise}
*/
export function saveSpritesheets(opts, images, spritesheets) {
opts.logger('Saving the spritesheets...');
return Promise.each(spritesheets, (spritesheet) => {
return (
_.isFunction(opts.hooks.onSaveSpritesheet) ?
Promise.resolve(opts.hooks.onSaveSpritesheet(opts, spritesheet)) :
Promise.resolve(makeSpritesheetPath(opts, spritesheet))
)
.then(( res ) => {
if (!res) {
throw new Error('postcss-sprites: Spritesheet requires a relative path.');
}
if ( _.isString(res) ) {
spritesheet.path = res;
} else {
_.assign(spritesheet, res);
}
spritesheet.path = spritesheet.path.replace(/\\/g, '/');
return fs.outputFileAsync(spritesheet.path, spritesheet.image);
});
}).then(spritesheets => {
return [opts, images, spritesheets];
});
}
/**
* Map spritesheet props to every image.
* @param {Object} opts
* @param {Array} images
* @param {Array} spritesheets
* @return {Promise}
*/
export function mapSpritesheetProps(opts, images, spritesheets) {
_.forEach(spritesheets, ({ coordinates, path, properties }) => {
const spritePath = path;
const spriteWidth = properties.width;
const spriteHeight = properties.height;
_.forEach(coordinates, (coords, imagePath) => {
_.chain(images)
.find(['path', imagePath])
.merge({
coords,
spritePath,
spriteWidth,
spriteHeight
})
.value();
});
});
return Promise.resolve([opts, images, spritesheets]);
}
/**
* Updates the CSS references.
* @param {Node} root
* @param {Object} opts
* @param {Array} images
* @param {Array} spritesheets
* @return {Promise}
*/
export function updateReferences(root, opts, images, spritesheets) {
opts.logger('Replacing the references...');
root.walkComments((comment) => {
let rule, image;
// Manipulate only comment tokens
if (isToken(comment.toString())) {
rule = comment.parent;
image = _.find(images, { url: comment.text });
// Update the rule with background declarations
if (image) {
// Generate CSS url to sprite
image.spriteUrl = path.relative(opts.stylesheetPath || path.dirname(root.source.input.file), image.spritePath);
image.spriteUrl = image.spriteUrl.split(path.sep).join('/');
// Update rule
if (_.isFunction(opts.hooks.onUpdateRule)) {
opts.hooks.onUpdateRule(rule, comment, image);
} else {
updateRule(rule, comment, image);
}
// Cleanup token
comment.remove();
}
}
});
return Promise.resolve([root, opts, images, spritesheets]);
}
/**
* Update an single CSS rule.
* @param {Node} rule
* @param {Node} token
* @param {Object} image
* @return
*/
export function updateRule(rule, token, image) {
const { retina, ratio, coords, spriteUrl, spriteWidth, spriteHeight } = image;
const posX = -1 * Math.abs(coords.x / ratio);
const posY = -1 * Math.abs(coords.y / ratio);
const sizeX = spriteWidth / ratio;
const sizeY = spriteHeight / ratio;
token.cloneAfter({
type: 'decl',
prop: 'background-image',
value: `url(${spriteUrl})`
}).cloneAfter({
prop: 'background-position',
value: `${posX}px ${posY}px`
}).cloneAfter({
prop: 'background-size',
value: `${sizeX}px ${sizeY}px`
});
}
/////////////////////////
// ----- Helpers ----- //
/////////////////////////
/**
* Checks for image url in the given CSS rules.
* @param {String} rule
* @return {Boolean}
*/
export function hasImageInRule(rule) {
return /background[^:]*.*url[^;]+/gi.test(rule);
}
/**
* Extracts the url of image from the given rule.
* @param {String} rule
* @return {Array}
*/
export function getImageUrl(rule) {
const matches = /url(?:\(['"]?)(.*?)(?:['"]?\))/gi.exec(rule);
let original = '';
let normalized = '';
if (matches) {
original = matches[1];
normalized = original
.replace(/['"]/gi, '') // replace all quotes
.replace(/\?.*$/gi, ''); // replace query params
}
return [original, normalized];
}
/**
* Checks whether the image is supported.
* @param {String} url
* @return {Boolean}
*/
export function isImageSupported(url) {
const http = /^http[s]?/gi;
const base64 = /^data\:image/gi;
return !http.test(url) && !base64.test(url);
}
/**
* Checks whether the image is retina.
* @param {String} url
* @return {Boolean}
*/
export function isRetinaImage(url) {
return /@(\d)x\.[a-z]{3,4}$/gi.test(url);
}
/**
* Extracts the retina ratio of image.
* @param {String} url
* @return {Number}
*/
export function getRetinaRatio(url) {
const matches = /@(\d)x\.[a-z]{3,4}$/gi.exec(url);
if (!matches) {
return 1;
}
return parseInt(matches[1], 10);
}
/**
* Extracts the color from background declaration.
* @param {String} declValue
* @return {String?}
*/
export function getColor(declValue) {
const regexes = ['(#([0-9a-f]{3}){1,2})', 'rgba?\\([^\\)]+\\)'];
let match = null;
_.forEach(regexes, (regex) => {
regex = new RegExp(regex, 'gi');
if (regex.test(declValue)) {
match = declValue.match(regex)[0];
}
});
return match;
}
/**
* Simple helper to avoid collisions with group names.
* @param {Boolean} toggle
* @return {Function}
*/
export function maskGroup(toggle = false) {
const input = new RegExp(`[${toggle ? GROUP_DELIMITER : GROUP_MASK }]`, 'gi');
const output = toggle ? GROUP_MASK : GROUP_DELIMITER;
return value => value.replace(input, output);
}
/**
* Generate the filepath to the sprite.
* @param {Object} opts
* @param {Object} spritesheet
* @return {String}
*/
export function makeSpritesheetPath(opts, { groups, extension }) {
return path.join(opts.spritePath, ['sprite', ...groups, extension].join('.'));
}
/**
* Check whether the comment is token that
* should be replaced with background declarations.
* @param {String} commentValue
* @return {Boolean}
*/
export function isToken(commentValue) {
return commentValue.indexOf(COMMENT_TOKEN_PREFIX) > -1;
}
/**
* Create a logger that can be disabled in runtime.
*
* @param {Boolean} enabled
* @return {Function}
*/
export function createLogger(enabled) {
if (enabled) {
debug.enable('postcss-sprites');
}
return debug('postcss-sprites');
}