diff --git a/README.md b/README.md index 9dd2ce7c..9b7a44ee 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Each one of them builds on top of the previous example so you can progess from b * [es6-fruits](https://github.com/givanse/broccoli-babel-examples/tree/master/es6-fruits) - Execute a single ES6 script. * [es6-website](https://github.com/givanse/broccoli-babel-examples/tree/master/es6-website) - Build a simple website. * [es6-modules](https://github.com/givanse/broccoli-babel-examples/tree/master/es6-modules) - Handle modules and unit tests. - + ## About source map Currently this plugin only supports inline source map. If you need @@ -64,3 +64,40 @@ You don't always need this, review which features need the polyfill here: [ES6 F var esTranspiler = require('broccoli-babel-transpiler'); var scriptTree = esTranspiler(inputTree, { browserPolyfill: true }); ``` + +## Plugins + +Use of custom plugins works similarly to `babel` itself. You would pass a `plugins` array in `options`: + +```js +var esTranspiler = require('broccoli-babel-transpiler'); +var applyFeatureFlags = require('babel-plugin-feature-flags'); + +var featureFlagPlugin = applyFeatureFlags({ + import: { module: 'ember-metal/features' }, + features: { + 'ember-metal-blah': true + } +}); + +var scriptTree = esTranspiler(inputTree, { + plugins: [ + featureFlagPlugin + ] +}); +``` + +### Caching + +broccoli-babel-transpiler uses a persistent cache to enable rebuilds to be significantly faster (by avoiding transpilation for files that have not changed). +However, since a plugin can do many things to affect the transpiled output it must also influence the cache key to ensure transpiled files are rebuilt +if the plugin changes (or the plugins configuration). + +In order to aid plugin developers in this process, broccoli-babel-transpiler will invoke two methods on a plugin so that it can augment the cache key: + +* `cacheKey` - This method is used to describe any runtime information that may want to invalidate the cached result of each file transpilation. This is + generally only needed when the configuration provided to the plugin is used to modify the AST output by a plugin like `babel-plugin-filter-imports` (module + exports to strip from a build), `babel-plugin-feature-flags` (configured features and current status to strip or embed in a final build), or + `babel-plugin-htmlbars-inline-precompile` (uses `ember-template-compiler.js` to compile inlined templates). +* `baseDir` - This method is expected to return the plugins base dir. The provided `baseDir` is used to ensure the cache is invalidated if any of the + plugin's files change (including its deps). Each plugin should implement `baseDir` as: `Plugin.prototype.baseDir = function() { return \_\_dirname; };`. diff --git a/fixtures/plugin-a/index.js b/fixtures/plugin-a/index.js new file mode 100644 index 00000000..9d717bd7 --- /dev/null +++ b/fixtures/plugin-a/index.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function() { + return 'plugin-a goes here but this isnt in plugin-b'; +}; diff --git a/fixtures/plugin-a/package.json b/fixtures/plugin-a/package.json new file mode 100644 index 00000000..a30f2af2 --- /dev/null +++ b/fixtures/plugin-a/package.json @@ -0,0 +1,5 @@ +{ + "name": "plugin-a", + "version": "1.0.0", + "main": "index.js" +} diff --git a/fixtures/plugin-b/index.js b/fixtures/plugin-b/index.js new file mode 100644 index 00000000..670c365e --- /dev/null +++ b/fixtures/plugin-b/index.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function() { + return 'plugin-b'; +}; diff --git a/fixtures/plugin-b/package.json b/fixtures/plugin-b/package.json new file mode 100644 index 00000000..6fb018e6 --- /dev/null +++ b/fixtures/plugin-b/package.json @@ -0,0 +1,5 @@ +{ + "name": "plugin-b", + "version": "1.0.0", + "main": "index.js" +} diff --git a/index.js b/index.js index 3c25f6cd..aeb1a7b3 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ var stringify = require('json-stable-stringify'); var mergeTrees = require('broccoli-merge-trees'); var funnel = require('broccoli-funnel'); var crypto = require('crypto'); +var hashForDep = require('hash-for-dep'); function getExtensionsRegex(extensions) { return extensions.map(function(extension) { @@ -34,6 +35,10 @@ function Babel(inputTree, _options) { Filter.call(this, inputTree, options); delete options.persist; + + this.console = options.console || console; + delete options.console; + this.options = options; this.moduleMetadata = {}; this.extensions = this.options.filterExtensions || ['js']; @@ -111,6 +116,66 @@ Babel.prototype.optionsHash = function() { hash[key] = (typeof value === 'function') ? (value + '') : value; } + if (options.plugins) { + hash.plugins = []; + + var cacheableItems = options.plugins.slice(); + + for (var i = 0; i < cacheableItems.length; i++) { + var item = cacheableItems[i]; + + var type = typeof item; + var augmentsCacheKey = false; + var providesBaseDir = false; + var requiresBaseDir = true; + + if (type === 'function') { + augmentsCacheKey = typeof item.cacheKey === 'function'; + providesBaseDir = typeof item.baseDir === 'function'; + + if (augmentsCacheKey) { + hash.plugins.push(item.cacheKey()); + } + + if (providesBaseDir) { + var depHash = hashForDep(item.baseDir()); + + hash.plugins.push(depHash); + } + + if (!providesBaseDir && requiresBaseDir){ + // prevent caching completely if the plugin doesn't provide baseDir + // we cannot ensure that we aren't causing invalid caching pain... + this.console.warn('broccoli-babel-transpiler is opting out of caching due to a plugin that does not provide a caching strategy: `' + item + '`.'); + hash.plugins.push((new Date).getTime() + '|' + Math.random()); + break; + } + } else if (Array.isArray(item)) { + item.forEach(function(part) { + cacheableItems.push(part); + }); + + continue; + } else if (type !== 'object' || item === null) { + // handle native strings, numbers, or null (which can JSON.stringify properly) + hash.plugins.push(item); + continue; + } else if (type === 'object') { + // itereate all keys in the item and push them into the cache + var keys = Object.keys(item); + keys.forEach(function(key) { + cacheableItems.push(key); + cacheableItems.push(item[key]); + }); + continue; + } else { + this.console.warn('broccoli-babel-transpiler is opting out of caching due to an non-cacheable item: `' + item + '` (' + type + ').'); + hash.plugins.push((new Date).getTime() + '|' + Math.random()); + break; + } + } + } + this._optionsHash = crypto.createHash('md5').update(stringify(hash), 'utf8').digest('hex'); } diff --git a/package.json b/package.json index 0270b7f4..746cf7a2 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,11 @@ "homepage": "https://github.com/babel/broccoli-babel-transpiler", "dependencies": { "babel-core": "^6.0.0", - "broccoli-persistent-filter": "^1.0.1", "broccoli-funnel": "^1.0.0", "broccoli-merge-trees": "^1.0.0", + "broccoli-persistent-filter": "^1.0.1", "clone": "^1.0.2", + "hash-for-dep": "^1.0.2", "json-stable-stringify": "^1.0.0" }, "devDependencies": { diff --git a/test.js b/test.js index e9b259b8..dc625610 100644 --- a/test.js +++ b/test.js @@ -416,12 +416,19 @@ describe('consume broccoli-babel-transpiler options', function() { }); describe('when options change', function() { - var originalHash, options; + var originalHash, options, fakeConsole, consoleMessages; beforeEach(function() { + fakeConsole = { + warn: function(message) { consoleMessages.push(message); } + }; + consoleMessages = []; + options = { bar: 1, - baz: function() {} + baz: function() {}, + console: fakeConsole, + plugins: [] }; var babel = new Babel('foo', options); @@ -431,13 +438,150 @@ describe('when options change', function() { it('clears cache for added properties', function() { options.foo = 1; + options.console = fakeConsole; + var babelNew = new Babel('foo', options); + + expect(babelNew.optionsHash()).to.not.eql(originalHash); + }); + + it('includes object plugins cacheKey result in hash', function() { + options.plugins = [ + { cacheKey: function() { return 'hi!'; }} + ]; + options.console = fakeConsole; + var babelNew = new Babel('foo', options); + + expect(babelNew.optionsHash()).to.not.eql(originalHash); + }); + + it('includes function plugins cacheKey result in hash', function() { + function fakePlugin() {} + fakePlugin.cacheKey = function() { return 'Hi!'; }; + + options.plugins = [ + fakePlugin + ]; + options.console = fakeConsole; + var babelNew = new Babel('foo', options); + + expect(babelNew.optionsHash()).to.not.eql(originalHash); + }); + + it('includes string plugins in hash calculation', function() { + options.plugins = [ + 'foo' + ]; + options.console = fakeConsole; var babelNew = new Babel('foo', options); expect(babelNew.optionsHash()).to.not.eql(originalHash); }); + it('includes plugins specified with options in hash calculation when cacheable', function() { + var pluginOptions = { foo: 'bar' }; + options.plugins = [ + ['foo', pluginOptions] + ]; + options.console = fakeConsole; + var first = new Babel('foo', options); + var firstOptions = first.optionsHash(); + + options.console = fakeConsole; + var second = new Babel('foo', options); + var secondOptions = second.optionsHash(); + expect(firstOptions).to.eql(secondOptions); + + pluginOptions.qux = 'huzzah'; + options.console = fakeConsole; + var third = new Babel('foo', options); + var thirdOptions = third.optionsHash(); + + expect(firstOptions).to.not.eql(thirdOptions); + }); + + it('invalidates plugins specified with options when not-cacheable', function() { + function thing() { } + var pluginOptions = { foo: 'bar', thing: thing }; + options.plugins = [ + ['foo', pluginOptions] + ]; + options.console = fakeConsole; + var first = new Babel('foo', options); + var firstOptions = first.optionsHash(); + + options.console = fakeConsole; + var second = new Babel('foo', options); + var secondOptions = second.optionsHash(); + expect(firstOptions).to.not.eql(secondOptions); + }); + + it('plugins specified with options can have functions with `baseDir`', function() { + var dir = path.join(inputPath, 'plugin-a'); + function thing() { } + thing.baseDir = function() { return dir; }; + var pluginOptions = { foo: 'bar', thing: thing }; + options.plugins = [ + ['foo', pluginOptions] + ]; + + options.console = fakeConsole; + var first = new Babel('foo', options); + var firstOptions = first.optionsHash(); + + options.console = fakeConsole; + var second = new Babel('foo', options); + var secondOptions = second.optionsHash(); + expect(firstOptions).to.eql(secondOptions); + + dir = path.join(inputPath, 'plugin-b'); + options.console = fakeConsole; + var third = new Babel('foo', options); + var thirdOptions = third.optionsHash(); + + expect(firstOptions).to.not.eql(thirdOptions); + }); + + it('a plugins `baseDir` method is used for hash generation', function() { + var dir = path.join(inputPath, 'plugin-a'); + + function plugin() {} + plugin.baseDir = function() { + return dir; + }; + options.plugins = [ plugin ]; + + options.console = fakeConsole; + var first = new Babel('foo', options); + var firstOptions = first.optionsHash(); + + dir = path.join(inputPath, 'plugin-b'); + options.console = fakeConsole; + var second = new Babel('foo', options); + var secondOptions = second.optionsHash(); + + expect(firstOptions).to.not.eql(secondOptions); + }); + + it('a plugin without a baseDir invalidates the cache every time', function() { + function plugin() {} + plugin.toString = function() { return ''; }; + options.plugins = [ plugin ]; + + options.console = fakeConsole; + var babel1 = new Babel('foo', options); + options.console = fakeConsole; + var babel2 = new Babel('foo', options); + + expect(babel1.optionsHash()).to.not.eql(babel2.optionsHash()); + expect(consoleMessages).to.eql([ + 'broccoli-babel-transpiler is opting out of caching due to a plugin that does not provide a caching strategy: ``.', + 'broccoli-babel-transpiler is opting out of caching due to a plugin that does not provide a caching strategy: ``.' + ]); + }); + it('clears cache for updated properties', function() { options.bar = 2; + options.console = fakeConsole; var babelNew = new Babel('foo', options); expect(babelNew.optionsHash()).to.not.eql(originalHash); @@ -445,6 +589,7 @@ describe('when options change', function() { it('clears cache for added methods', function() { options.foo = function() {}; + options.console = fakeConsole; var babelNew = new Babel('foo', options); expect(babelNew.optionsHash()).to.not.eql(originalHash); @@ -452,8 +597,9 @@ describe('when options change', function() { it('clears cache for updated methods', function() { options.baz = function() { return 1; }; + options.console = fakeConsole; var babelNew = new Babel('foo', options); expect(babelNew.optionsHash()).to.not.eql(originalHash); - }); + }); });