Add experimental Webpack support. Works fine when no cache is involved, but I need to figure out a better story for cache integration as most builds that teams use likely include a cache enabled.

This commit is contained in:
Ryan McGrath 2016-03-21 19:39:16 +09:00
parent f2040233d0
commit 3801044178
18 changed files with 40032 additions and 486 deletions

30
lib/BabelSVGTracker.js Normal file
View file

@ -0,0 +1,30 @@
/**
* A Babel "plugin/transformer" that just tracks unique <Icon.../>
* JSX tags in your source code. Note that this does absolutely no
* modifications - just accumulates the unique SVG uris for the mapping.
*
* @param {Object} babel This will be auto-passed, most likely, by Babel.
* @returns {Object} babel.Transformer used for the babel'ing. You know the one.
*/
module.exports = function(babel) {
var _packer = this;
return {
visitor: {
JSXElement: function JSXElement(node, parent, scope, file) {
if(_packer.opts.JSXTagNames.indexOf(node.node.openingElement.name.name) < 0)
return;
var attributes = node.node.openingElement.attributes,
l = attributes.length,
i = 0;
for(; i < l; i++) {
if(attributes[i].name.name !== 'uri')
continue;
_packer.SVGS[attributes[i].value.value] = 1;
}
}
}
};
};

214
lib/IconPacker.js Normal file
View file

@ -0,0 +1,214 @@
/**
* iconpacker.js
*
* This just encapsulates the core icon-packer functionality. index.js
* is the main entry point to care about insofar as usage.
*
* @author Ryan McGrath <ryan@rymc.io
*/
var SVGO = require('svgo'),
fs = require('fs'),
async = require('async'),
through = require('through2'),
merge = require('lodash').merge,
cache = require('./cache/fs-cache.js'),
ReactSVGComponentFilePath = require.resolve('../components/svg.js');
/**
* IconPackerer constructor.
*
* @constructor
* @param {Object} options Options
* @return {Object} An instance of IconPacker to do things with.
*/
var IconPacker = function(options) {
if(!(this instanceof IconPacker))
return new IconPacker(options);
// This is a reference to the directory where we've got
// provided SVG files held. The user can specify a root directory of
// their own in the options below; we'll always check theirs first, falling
// back to checking ours if there's no hit.
this._internalSVGDirectory = __dirname + '/../svgs/';
// This is our grand database of SVGs. We do some caching here to see if we have
// an existing batch of SVGs loaded.
this.SVGS = {};
// Configure our options, if applied here
this.opts = {};
this.update(options);
// Basic binding() and such for pieces we use later on in the lifepsan
// of this object. See the respective methods for more details.
this.svgo = new SVGO(this.opts.svgo);
this.readAndOptimizeSVGs = this._readAndOptimizeSVGs.bind(this);
return this;
};
IconPacker.prototype.update = function(options) {
this.opts = merge({
verbose: false,
// JSX tags to pick up
JSXTagNames: ['Icon'],
// We provide some default SVGO options that work well for the icons in this
// project; users can override but keep in mind that it's ill-advised at the time
// of writing this to remove the viewBox attributes. This may change in a later release.
//
// (Removing the viewBox can cause very wonky issues in IE, and it helps to size the various icons).
svgSourceDirectory: '',
svgo: {
plugins: [
{removeViewBox: false}, // See note above ---^
{removeUselessStrokeAndFill: false}, // This can clobber some icons, just leave it.
{removeEmptyAttrs: false}, // I find this helpful, others may not.
{removeDimensions: true}
]
}
}, this.opts, options);
return this;
};
/**
* Handles async reading in all the SVG files and optimizing them. Really
* nothing too special here - just async.map'd later on down the file.
*
* @param {Function} callback Upon completion this junks runs.
*/
IconPacker.prototype._readAndOptimizeSVGs = function(key, callback) {
var packer = this,
internalFilePath = this._internalSVGDirectory + key + '.svg',
filePath = packer.opts.svgSourceDirectory + key + '.svg';
var onSVGOComplete = function(result) {
packer.SVGS[key] = result;
callback(null, result);
};
fs.readFile(filePath, 'utf8', function(error, data) {
if(!error)
return packer.svgo.optimize(data, onSVGOComplete);
fs.readFile(internalFilePath, 'utf8', function(e, d) {
if(!e)
return packer.svgo.optimize(d, onSVGOComplete);
console.warn('Warning: Could not load ' + key);
delete packer.SVGS[key]; // Better to error out on the client, I think
return callback(e, null);
});
});
};
/**
* Handles actually compiling the SVG assets into a module Browserify module, then passing
* it into the build stream for inclusion in the final bundle. I'll note that this
* was a massive PITA to decipher how to do - comments are your friends, Node devs.
*
* This is the only method that is "callback-hell"-ish, but I've chosen not to break
* it up because it still fits on one screen of code and is follow-able enough.
*
* @returns {Function} A through2 stream in object mode, which Browserify needs.
*/
IconPacker.prototype.compileForBrowserify = function() {
var packer = this,
write = function(buf, enc, next) {
next(null, buf);
};
var end = function(next) {
var keys = Object.keys(packer.SVGS),
stream = this;
async.map(keys, packer.readAndOptimizeSVGs, function(error, results) {
fs.readFile(ReactSVGComponentFilePath, 'utf8', function(e, data) {
stream.push({
id: 'react-iconpack',
externalRequireName: 'react-iconpack',
standaloneModule: 'react-iconpack',
hasExports: true,
source: data,
// An empty deps object is important here as we don't want to
// accidentally bundle a second copy of React or the icons as
// they're require()'d in the source. Don't change this.
deps: {}
});
var icons = {
id: 'react-iconpack-icons',
externalRequireName: 'react-iconpack-icons',
standaloneModule: 'react-iconpack-icons',
hasExports: true,
deps: {}
};
icons.source = packer.compileSVGs.call(packer);
stream.push(icons);
next();
});
});
}
return through.obj(write, end);
};
/**
* Whereas Browserify requires a really annoying Stream setup, Webpack is much more
* straightforward. Thus, internally there's two separate compile methods for this stuff.
*
* @param {Function} callbackfn A callback for when the module is ready, as it's async in nature.
* @returns {String} module The bundled module.
*/
IconPacker.prototype.compileForWebpack = function(callbackfn) {
var keys = Object.keys(this.SVGS);
async.map(keys, this.readAndOptimizeSVGs, function(error, results) {
callbackfn(this.compileSVGs());
}.bind(this));
};
/**
* Compiles the currently loaded SVGs into a JS module, in preparation for
* injection into the bundle. The outputted code is optimized slightly for readability,
* debugging and so on.
*
* Side note: yes, parsing "HTML/XML/etc" with regex is dumb. This is also totally fine
* for right now though.
*
* @returns {String} The source for this "module" as a String, which Browserify needs.
*/
IconPacker.prototype.compileSVGs = function() {
var keys = Object.keys(this.SVGS),
key,
i = 0,
l = keys.length,
viewBoxRegex = /<svg.*?viewBox="(.*?)"/,
matches;
var code = 'module.exports = {\n';
code += ' icons: {\n';
for(; i < l; i++) {
key = keys[i];
var viewBox = null;
matches = viewBoxRegex.exec(this.SVGS[key].data);
if(matches)
viewBox = matches[1];
code += ' "' + key + '": {\n';
code += ' props: {viewBox: ' + (viewBox ? '"' + viewBox + '"' : null) + '},\n';
code += " data: '" + (this.SVGS[key].data ? this.SVGS[key].data.replace('<\/svg>', '')
.replace(/<\s*svg.*?>/ig, '') : "") + "',\n},\n";
}
return code + '}\n};\n';
};
module.exports = IconPacker;

103
lib/WebpackPlugin.js Normal file
View file

@ -0,0 +1,103 @@
/**
* WebpackPlugin.js
*
* The Webpack plugin for react-iconpack. Manages intercepting the
* build/loader/etc processes and injecting the SVG icon set as a module.
*
* @author Ryan McGrath <ryan@rymc.io>
*/
var path = require('path'),
ReactSVGComponentFilePath = require.resolve('../components/svg.js'),
IconStubComponentPath = require.resolve('../components/react-iconpack-icons.js');
/**
* WebpackPlugin
*
* Accepts a packer, and options for configuring the packer instance itself.
* @Note: You likely don't want this, but the API in index.js.
*
* @param {Object} packer An instance of IconPacker (see lib/IconPacker.js).
* @param {Object} opts Options for configuring IconPacker (for certain use cases it's ideal to update here).
* @returns {Object} WebpackPlugin A new Webpack plugin.
*/
var WebpackPlugin = function(packer, opts) {
this.packer = packer;
this.packer.update(opts);
return this;
};
/**
* WebpackPlugin.loader()
*
* This is Webpack-specific functionality, which is why it's here - not looking
* to overly pollute index.js. This should be .call()'d with the loader context from
* index.js, along with the options.
*
* @param {Object} engine The engine object from index.js, which is just the source code.
* @param {Object} packer The packer object from index.js, shared amongst everything.
* @returns {void}
*/
WebpackPlugin.loader = function(engine, packer) {
var callback = this.async();
packer.compileForWebpack(function(source) {
callback(null, engine.replace('module.exports = {react_iconpack_icons: {}};', source));
});
};
/**
* This prototype.apply chain satisfies the Webpack plugin API. To be quite honest, the
* documentation surrounding Webpack plugin development is (almost, but not quite) as bad
* as Browserify. Really annoying to decipher.
*
* What we essentially do here is the following:
*
* - With each attempt to resolve a module, we check to see if it's one of our
* react-icon-* modules. With both of them, we redirect the request to a file in
* the component directory. This stops Webpack from blowing up and lets us provide
* a nicer API.
*
* - After module resolution is done, we check to see if the one being passed is
* the icons file. We specifically want to apply a loader (our index.js file) to
* replace the module source code with our true icons source code.
*
* This takes advantage of how Webpack consumes loaders. I originally wanted to make use
* of the compilation.addModule call but I could not figure out that undocumented... thing for
* the life of me (creating a RawModule and adding it did nothing).
*/
WebpackPlugin.prototype.apply = function(compiler) {
var packer = this.packer;
compiler.plugin('normal-module-factory', function(nmf) {
nmf.plugin('before-resolve', function(result, callback) {
if(!result)
return callback();
// We'll patch them in later... (see index.js)
if(/react-iconpack-icons$/.test(result.request))
result.request = IconStubComponentPath;
// Inject react-iconpack Component
// To do this we actually want to redirect the require statement directive
// to the proper file~
if(/react-iconpack$/.test(result.request))
result.request = ReactSVGComponentFilePath;
callback(null, result);
});
// This callback function parameter isn't even in the damn docs, just guessed at it...
// At any rate, if the icons module is detected we push our loader to the front of it
// What's nice is that we can do it ourselves and not require the user to do more configuration
// Note: this is called for every module, so the check is important.
nmf.plugin('after-resolve', function(data, callback) {
if(/react-iconpack-icons\.js/.test(data.request))
data.loaders.unshift(path.join(__dirname, '../index.js'));
callback(null, data);
});
});
};
module.exports = WebpackPlugin;

View file

@ -0,0 +1,27 @@
/**
* A Browserify plugin that hooks in to the Browserify build pipeline and injects
* your icons and a tag as a require()-able module.
*
* Originally I wanted to find a way to just have Babel shove this in, but I
* couldn't figure out a way to do it cleanly so this works.
*
* Note: you almost never need to call this yourself; you really just wanna pass
* it to Browserify instead. See the full documentation for more details.
*
* @param {Object} browserify An instance of Browserify for this to hook into.
* @param {Object} opts Options for the icon packer instance itself.
* @returns {Object} browserify The instance being operated on.
*/
module.exports = function(browserify, opts) {
this.update(opts);
var startListeningToThisCompleteMessOfALibraryAgain = function() {
browserify.pipeline.get('pack').unshift(this.compileForBrowserify());
}.bind(this);
browserify.external('react-iconpack');
browserify.external('react-iconpack-icons');
browserify.on('reset', startListeningToThisCompleteMessOfALibraryAgain);
startListeningToThisCompleteMessOfALibraryAgain();
return browserify;
};

136
lib/cache/fs-cache.js vendored Normal file
View file

@ -0,0 +1,136 @@
'use strict';
/**
* Filesystem cache
*
* Given a file and a transform function, cache the result into files
* or retrieve the previously cached files if the given file is already known.
*
* Note: This is shamelessly lifted and modified from babel-loader's cache. See
* https://github.com/babel/babel-loader/blob/master/lib/fs-cache.js
* -- Ryan McGrath, who'd rather not reinvent the wheel
*
* @see https://github.com/babel/babel-loader/issues/34
* @see https://github.com/babel/babel-loader/pull/41
*/
var crypto = require('crypto');
var mkdirp = require('mkdirp');
var fs = require('fs');
var os = require('os');
var path = require('path');
var zlib = require('zlib');
/**
* Read the contents from the compressed file.
*
* @async
* @params {String} filename
* @params {Function} callback
*/
var read = function(filename, callback) {
return fs.readFile(filename, function(err, data) {
if(err)
return callback(err);
return zlib.gunzip(data, function(err, content) {
var result = {};
if(err)
return callback(err);
try {
result = JSON.parse(content);
} catch (e) {
return callback(e);
}
return callback(null, result);
});
});
};
/**
* Write contents into a compressed file.
*
* @async
* @params {String} filename
* @params {String} result
* @params {Function} callback
*/
var write = function(filename, result, callback) {
var content = JSON.stringify(result);
return zlib.gzip(content, function(err, data) {
if(err)
return callback(err);
return fs.writeFile(filename, data, callback);
});
};
/**
* Build the filename for the cached file
*
* @params {String} source File source code
* @params {Object} options Options used
*
* @return {String}
*/
var filename = function(source, identifier, options) {
var hash = crypto.createHash('SHA1');
var contents = JSON.stringify({
source: source,
identifier: identifier,
});
hash.end(contents);
return hash.read().toString('hex') + '.json.gzip';
};
/**
* Retrieve file from cache, or create a new one for future reads
*
* @async
* @param {Object} params
* @param {String} params.identifier Unique identifier to bust cache
* @param {String} params.source Original contents of the file to be cached
* @param {Function<err, result>} callback
*
* @example
*
* cache({
* identifier: '',
* source: *source code from file*,
* }, function(err, result) {
*
* });
*/
var cache = module.exports = function(params, callback) {
// Spread params into named variables
// Forgive user whenever possible
var source = params.source;
var identifier = params.identifier;
var directory = os.tmpdir();
var file = path.join(directory, filename(source, identifier));
// Make sure the directory exists.
return mkdirp(directory, function(err) {
if(err)
return callback(err);
return read(file, function(err, content) {
var result = {};
// No errors mean that the file was previously cached
// we just need to return it
if(!err)
return callback(null, content);
// return it to the user asap and write it in cache
return write(file, result, function(err) {
return callback(err, result);
});
});
});
};