diff --git a/lib/cli/convert.js b/lib/cli/convert.js index 95db89e..3f4bd5f 100644 --- a/lib/cli/convert.js +++ b/lib/cli/convert.js @@ -139,8 +139,7 @@ function usage(puts) { puts(bold('Formats')); puts(); puts(' commonjs - convert modules to files using CommonJS `require` and `exports` objects.'); - puts(' module-variable - concatenate modules optimizing for runtime performance.'); - puts(' export-variable - concatenate modules optimizing for compressibility (enables better tree-shaking).'); + puts(' bundle - concatenate modules into a single file.'); puts(); puts(' You may provide custom a formatter by passing the path to your module to the `--format` option. See the'); puts(' source of any of the built-in formatters for details on how to build your own.'); diff --git a/lib/formatters.js b/lib/formatters.js index a447e9b..01839b1 100644 --- a/lib/formatters.js +++ b/lib/formatters.js @@ -1,6 +1,5 @@ /* jshint node:true, undef:true, unused:true */ -exports.DEFAULT = 'module-variable'; -exports['module-variable'] = require('./formatters/module_variable_formatter'); -exports['export-variable'] = require('./formatters/export_variable_formatter'); +exports.DEFAULT = 'bundle'; +exports.bundle = require('./formatters/bundle_formatter'); exports.commonjs = require('./formatters/commonjs_formatter'); diff --git a/lib/formatters/bundle_formatter.js b/lib/formatters/bundle_formatter.js new file mode 100644 index 0000000..5f15c34 --- /dev/null +++ b/lib/formatters/bundle_formatter.js @@ -0,0 +1,315 @@ +/* jshint node:true, undef:true, unused:true */ + +var recast = require('recast'); +var types = recast.types; +var b = types.builders; +var n = types.namedTypes; + +var Replacement = require('../replacement'); +var utils = require('../utils'); +var IFFE = utils.IFFE; +var sort = require('../sorting').sort; + + +/** + * The 'bundle' formatter aims to increase the compressibility of the generated + * source, especially by tools such as Google Closure Compiler or UglifyJS. + * For example, given these modules: + * + * // a.js + * import { b } from './b'; + * console.log(b); + * + * // b.js + * export var b = 3; + * export var b2 = 6; + * + * The final output will be a single file looking something like this: + * + * (function() { + * "use strict"; + * // b.js + * var b$$b = 3; + * var b$$b2 = 6; + * + * // a.js + * console.log(b$$b); + * }).call(this); + * + * @constructor + */ +function BundleFormatter() {} + +/** + * This hook is called by the container before it converts its modules. We use + * it to ensure all of the imports are included because we need to know about + * them at compile time. + * + * @param {Container} container + */ +BundleFormatter.prototype.beforeConvert = function(container) { + container.findImportedModules(); +}; + +/** + * Returns an expression which globally references the export named by + * `identifier` for the given module `mod`. + * + * @param {Module} mod + * @param {ast-types.Identifier|string} identifier + * @return {ast-types.MemberExpression} + */ +BundleFormatter.prototype.reference = function(/* mod, identifier */) { + throw new Error('#reference must be implemented in subclasses'); +}; + +/** + * Process a variable declaration found at the top level of the module. We need + * to ensure that exported variables are rewritten appropriately, so we may + * need to rewrite some or all of this variable declaration. For example: + * + * var a = 1, b, c = 3; + * ... + * export { a, b }; + * + * We turn those being exported into assignments as needed, e.g. + * + * var c = 3; + * mod$$a = 1; + * + * @param {Module} mod + * @param {ast-types.NodePath} nodePath + * @return {?Replacement} + */ +BundleFormatter.prototype.processVariableDeclaration = function(mod, nodePath) { + var self = this; + return Replacement.map( + nodePath.get('declarations'), + function(declaratorPath) { + return Replacement.swaps( + declaratorPath.get('id'), + self.reference(mod, declaratorPath.get('id').node) + ); + } + ); +}; + +/** + * Rename the top-level function declaration to a unique name. + * + * function foo() {} + * + * Becomes e.g. + * + * function mod$$foo() {} + * + * @param {Module} mod + * @param {ast-types.NodePath} nodePath + * @return {?Replacement} + */ +BundleFormatter.prototype.processFunctionDeclaration = function(mod, nodePath) { + return Replacement.swaps( + nodePath.get('id'), + this.reference(mod, nodePath.node.id) + ); +}; + +/** + * Replaces non-default exports. Exported bindings do not need to be + * replaced with actual statements since they only control how local references + * are renamed within the module. + * + * @param {Module} mod + * @param {ast-types.NodePath} nodePath + * @return {?Replacement} + */ +BundleFormatter.prototype.processExportDeclaration = function(mod, nodePath) { + var node = nodePath.node; + if (n.FunctionDeclaration.check(node.declaration)) { + return Replacement.swaps( + // drop `export` + nodePath, node.declaration + ).and( + // transform the function + this.processFunctionDeclaration(mod, nodePath.get('declaration')) + ); + } else if (n.VariableDeclaration.check(node.declaration)) { + return Replacement.swaps( + // drop `export` + nodePath, node.declaration + ).and( + // transform the variables + this.processVariableDeclaration(mod, nodePath.get('declaration')) + ); + } else if (node.declaration) { + throw new Error( + 'unexpected export style, found a declaration of type: ' + + node.declaration.type + ); + } else { + /** + * This node looks like this: + * + * export { foo, bar }; + * + * Which means that it has no value in the generated code as its only + * function is to control how imports are rewritten. + */ + return Replacement.removes(nodePath); + } +}; + +/** + * Since import declarations only control how we rewrite references we can just + * remove them -- they don't turn into any actual statements. + * + * @param {Module} mod + * @param {ast-types.NodePath} nodePath + * @return {?Replacement} + */ +BundleFormatter.prototype.processImportDeclaration = function(mod, nodePath) { + return Replacement.removes(nodePath); +}; + +/** + * Get a reference to the original exported value referenced in `mod` at + * `referencePath`. If the given reference path does not correspond to an + * export, we do not need to rewrite the reference. For example, since `value` + * is not exported it does not need to be rewritten: + * + * // a.js + * var value = 99; + * console.log(value); + * + * If `value` was exported then we would need to rewrite it: + * + * // a.js + * export var value = 3; + * console.log(value); + * + * In this case we re-write both `value` references to something like + * `a$$value`. The tricky part happens when we re-export an imported binding: + * + * // a.js + * export var value = 11; + * + * // b.js + * import { value } from './a'; + * export { value }; + * + * // c.js + * import { value } from './b'; + * console.log(value); + * + * The `value` reference in a.js will be rewritten as something like `a$$value` + * as expected. The `value` reference in c.js will not be rewritten as + * `b$$value` despite the fact that it is imported from b.js. This is because + * we must follow the binding through to its import from a.js. Thus, our + * `value` references will both be rewritten to `a$$value` to ensure they + * match. + * + * @param {Module} mod + * @param {ast-types.NodePath} referencePath + * @return {ast-types.Expression} + */ +BundleFormatter.prototype.exportedReference = function(mod, referencePath) { + var specifier = mod.exports.findSpecifierForReference(referencePath); + if (specifier) { + specifier = specifier.terminalExportSpecifier; + return this.reference(specifier.module, specifier.name); + } else { + return null; + } +}; + +/** + * Get a reference to the original exported value referenced in `mod` at + * `referencePath`. This is very similar to {#exportedReference} in its + * approach. + * + * @param {Module} mod + * @param {ast-types.NodePath} referencePath + * @return {ast-types.Expression} + * @see {#exportedReference} + */ +BundleFormatter.prototype.importedReference = function(mod, referencePath) { + var specifier = mod.imports.findSpecifierForReference(referencePath); + if (specifier) { + specifier = specifier.terminalExportSpecifier; + return this.reference(specifier.module, specifier.name); + } else { + return null; + } +}; + +/** + * If the given reference has a local declaration at the top-level then we must + * rewrite that reference to have a module-scoped name. + * + * @param {Module} mod + * @param {ast-types.NodePath} referencePath + * @returns {?ast-types.Node} + */ +BundleFormatter.prototype.localReference = function(mod, referencePath) { + var scope = referencePath.scope.lookup(referencePath.node.name); + if (scope && scope.isGlobal) { + return this.reference(mod, referencePath.node); + } else { + return null; + } +}; + +/** + * Convert a list of ordered modules into a list of files. + * + * @param {Array.} modules Modules in execution order. + * @return {Array.} modules - * @return {ast-types.VariableDeclaration} - */ -ExportVariableFormatter.prototype.variableDeclaration = function(modules) { - var declarators = []; - var self = this; - - modules.forEach(function(mod) { - mod.exports.names.forEach(function(name) { - declarators.push( - b.variableDeclarator(self.reference(mod, name), null) - ); - }); - }); - - if (declarators.length > 0) { - return b.variableDeclaration('var', declarators); - } else { - return b.emptyStatement(); - } -}; - -module.exports = ExportVariableFormatter; diff --git a/lib/formatters/module_variable_formatter.js b/lib/formatters/module_variable_formatter.js deleted file mode 100644 index ec503f9..0000000 --- a/lib/formatters/module_variable_formatter.js +++ /dev/null @@ -1,97 +0,0 @@ -/* jshint node:true, undef:true, unused:true */ - -var recast = require('recast'); -var types = recast.types; -var n = types.namedTypes; -var b = types.builders; - -var utils = require('../utils'); -var extend = utils.extend; - -var VariableFormatterBase = require('./variable_formatter_base'); - -/** - * The 'module-variable' setting for referencing exports aims to reduce the - * number of variables in the outermost IFFE scope. This avoids potentially - * quadratic performance degradations as shown by - * https://gist.github.com/joliss/9331281. For example, given these modules: - * - * // a.js - * import { b } from './b'; - * console.log(b); - * - * // b.js - * export var b = 3; - * export var b2 = 6; - * - * The final output will be a single file looking something like this: - * - * (function() { - * var b$$ = {}; - * - * (function() { - * // b.js - * b$$.b = 3; - * b$$.b2 = 6; - * })(); - * - * (function() { - * // a.js - * console.log(b$$.b); - * })(); - * })(); - * - * @constructor - * @extends VariableFormatterBase - */ -function ModuleVariableFormatter() {} -extend(ModuleVariableFormatter, VariableFormatterBase); - -/** - * Returns an expression which globally references the export named by - * `identifier` for the given module `mod`. For example: - * - * // rsvp/defer.js, export default - * rsvp$defer$$.default - * - * // rsvp/utils.js, export function isFunction - * rsvp$utils$$.isFunction - * - * @param {Module} mod - * @param {ast-types.Identifier} identifier - * @return {ast-types.MemberExpression} - */ -ModuleVariableFormatter.prototype.reference = function(mod, identifier) { - return b.memberExpression( - b.identifier(mod.id), - n.Identifier.check(identifier) ? identifier : b.identifier(identifier), - false - ); -}; - -/** - * Returns a declaration for the exports for the given modules. In this case, - * this is always just the the module objects (e.g. `var rsvp$defer$$ = {};`). - * - * @param {Array.} modules - * @return {ast-types.VariableDeclaration} - */ -ModuleVariableFormatter.prototype.variableDeclaration = function(modules) { - var declarators = []; - - modules.forEach(function(mod) { - if (mod.exports.names.length > 0) { - declarators.push( - b.variableDeclarator(b.identifier(mod.id), b.objectExpression([])) - ); - } - }); - - if (declarators.length > 0) { - return b.variableDeclaration('var', declarators); - } else { - return b.emptyStatement(); - } -}; - -module.exports = ModuleVariableFormatter; diff --git a/lib/formatters/variable_formatter_base.js b/lib/formatters/variable_formatter_base.js deleted file mode 100644 index 2db534d..0000000 --- a/lib/formatters/variable_formatter_base.js +++ /dev/null @@ -1,396 +0,0 @@ -/* jshint node:true, undef:true, unused:true */ - -var recast = require('recast'); -var types = recast.types; -var b = types.builders; -var n = types.namedTypes; - -var Replacement = require('../replacement'); -var utils = require('../utils'); -var IFFE = utils.IFFE; -var sort = require('../sorting').sort; - - -/** - * The variable class of export strategies concatenate all modules together, - * isolating their local variables. All exports and imports are rewritten to - * reference variables in a shared parent scope. For example, given these - * modules: - * - * // a.js - * import { b } from './b'; - * - * // b.js - * export var b = 3; - * - * The final output will be a single file looking something like this: - * - * (function() { - * // variable declarations here - * - * (function() { - * // b.js, sets variables declared above - * })(); - * - * (function() { - * // a.js, references variables declared above - * })(); - * })(); - * - * @constructor - */ -function VariableFormatterBase() {} - -/** - * This hook is called by the container before it converts its modules. We use - * it to ensure all of the imports are included because we need to know about - * them at compile time. - * - * @param {Container} container - */ -VariableFormatterBase.prototype.beforeConvert = function(container) { - container.findImportedModules(); -}; - -/** - * Returns an expression which globally references the export named by - * `identifier` for the given module `mod`. - * - * @param {Module} mod - * @param {ast-types.Identifier|string} identifier - * @return {ast-types.MemberExpression} - */ -VariableFormatterBase.prototype.reference = function(/* mod, identifier */) { - throw new Error('#reference must be implemented in subclasses'); -}; - -/** - * Process a variable declaration found at the top level of the module. We need - * to ensure that exported variables are rewritten appropriately, so we may - * need to rewrite some or all of this variable declaration. For example: - * - * var a = 1, b, c = 3; - * ... - * export { a, b }; - * - * We turn those being exported into assignments as needed, e.g. - * - * var c = 3; - * mod$$a = 1; - * - * @param {Module} mod - * @param {ast-types.NodePath} nodePath - * @return {?Replacement} - */ -VariableFormatterBase.prototype.processVariableDeclaration = function(mod, nodePath) { - var exports = mod.exports; - var node = nodePath.node; - var self = this; - var declarators = []; - var assignments = []; - var exportSpecifier; - - node.declarations.forEach(function(declarator) { - exportSpecifier = exports.findSpecifierByName(declarator.id.name); - if (exportSpecifier) { - // This variable is exported, turn it into a normal assignment. - if (declarator.init) { - // But only if we have something to assign with. - assignments.push( - b.assignmentExpression( - '=', - self.reference(mod, declarator.id), - declarator.init - ) - ); - } - } else { - // This variable is not exported, so keep it. - declarators.push(declarator); - } - }); - - // Don't bother replacing it if we kept all declarators. - if (node.declarations.length === declarators.length) { - return null; - } - - var nodes = []; - - // Do we need a variable declaration at all? - if (declarators.length > 0) { - nodes.push( - b.variableDeclaration(node.kind, declarators) - ); - } - - // Do we have any assignments to add? - if (assignments.length > 0) { - nodes.push(b.expressionStatement( - b.sequenceExpression(assignments) - )); - } - return Replacement.swaps(nodePath, nodes); -}; - -/** - * Appends an assignment after a function declaration if that function is exported. For example, - * - * function foo() {} - * // ... - * export { foo }; - * - * Becomes e.g. - * - * function foo() {} - * mod$$foo = foo; - * - * @param {Module} mod - * @param {ast-types.NodePath} nodePath - * @return {?Replacement} - */ -VariableFormatterBase.prototype.processFunctionDeclaration = function(mod, nodePath) { - var exports = mod.exports; - var node = nodePath.node; - var exportSpecifier = exports.findSpecifierByName(node.id.name); - - // No need for an assignment to an export if it isn't exported. - if (!exportSpecifier) { - return null; - } - - // Add an assignment, e.g. `mod$$foo = foo`. - var assignment = b.expressionStatement( - b.assignmentExpression( - '=', - this.reference(mod, exportSpecifier.name), - node.id - ) - ); - - return Replacement.adds(nodePath, assignment); -}; - -/** - * Replaces non-default exports. Since we mostly rewrite references we may need - * to deconstruct variable declarations. Exported bindings do not need to be - * replaced with actual statements since they only control reference rewrites. - * - * @param {Module} mod - * @param {ast-types.NodePath} nodePath - * @return {?Replacement} - */ -VariableFormatterBase.prototype.processExportDeclaration = function(mod, nodePath) { - var node = nodePath.node; - var self = this; - if (n.FunctionDeclaration.check(node.declaration)) { - /** - * Function exports are declarations and so should not be - * re-assignable, so we can safely turn remove the `export` keyword and - * add an assignment. For example: - * - * export function add(a, b) { return a + b; } - * - * Becomes: - * - * function add(a, b) { return a + b; } - * mod$$.add = add; - */ - return Replacement.swaps( - nodePath, - [ - node.declaration, - b.expressionStatement( - b.assignmentExpression( - '=', - this.reference(mod, node.declaration.id), - node.declaration.id - ) - ) - ] - ); - } else if (n.VariableDeclaration.check(node.declaration)) { - /** - * Variable exports can be re-assigned, so we need to rewrite the - * names. For example: - * - * export var a = 1, b = 2; - * - * Becomes: - * - * mod$$.a = 1, mod$$.b = 2; - */ - return Replacement.swaps( - nodePath, - b.expressionStatement( - b.sequenceExpression( - node.declaration.declarations.reduce(function(assignments, declarator) { - if (declarator.init) { - assignments.push( - b.assignmentExpression( - '=', - self.reference(mod, declarator.id), - declarator.init - ) - ); - } - return assignments; - }, []) - ) - ) - ); - } else if (node.declaration) { - throw new Error('unexpected export style, found a declaration of type: ' + node.declaration.type); - } else { - /** - * For exports with a named specifier list we handle them by re-writing - * their declaration (if it's in this module) and their references, so - * the export declaration can be safely removed. - */ - return Replacement.removes(nodePath); - } -}; - -/** - * Since import declarations only control how we rewrite references we can just - * remove them -- they don't turn into any actual statements. - * - * @param {Module} mod - * @param {ast-types.NodePath} nodePath - * @return {?Replacement} - */ -VariableFormatterBase.prototype.processImportDeclaration = function(mod, nodePath) { - return Replacement.removes(nodePath); -}; - -/** - * Get a reference to the original exported value referenced in `mod` at - * `referencePath`. If the given reference path does not correspond to an - * export, we do not need to rewrite the reference. For example, since `value` - * is not exported it does not need to be rewritten: - * - * // a.js - * var value = 99; - * console.log(value); - * - * If `value` was exported then we would need to rewrite it: - * - * // a.js - * export var value = 3; - * console.log(value); - * - * In this case we re-write both `value` references to something like - * `a$$value`. The tricky part happens when we re-export an imported binding: - * - * // a.js - * export var value = 11; - * - * // b.js - * import { value } from './a'; - * export { value }; - * - * // c.js - * import { value } from './b'; - * console.log(value); - * - * The `value` reference in a.js will be rewritten as something like `a$$value` - * as expected. The `value` reference in c.js will not be rewritten as - * `b$$value` despite the fact that it is imported from b.js. This is because - * we must follow the binding through to its import from a.js. Thus, our - * `value` references will both be rewritten to `a$$value` to ensure they - * match. - * - * @param {Module} mod - * @param {ast-types.NodePath} referencePath - * @return {ast-types.Expression} - */ -VariableFormatterBase.prototype.exportedReference = function(mod, referencePath) { - var specifier = mod.exports.findSpecifierForReference(referencePath); - if (specifier) { - specifier = specifier.terminalExportSpecifier; - return this.reference(specifier.module, specifier.name); - } else { - return null; - } -}; - -/** - * Get a reference to the original exported value referenced in `mod` at - * `referencePath`. This is very similar to {#exportedReference} in its - * approach. - * - * @param {Module} mod - * @param {ast-types.NodePath} referencePath - * @return {ast-types.Expression} - * @see {#exportedReference} - */ -VariableFormatterBase.prototype.importedReference = function(mod, referencePath) { - var specifier = mod.imports.findSpecifierForReference(referencePath); - if (specifier) { - specifier = specifier.terminalExportSpecifier; - return this.reference(specifier.module, specifier.name); - } else { - return null; - } -}; - -/** - * Convert a list of ordered modules into a list of files. - * - * @param {Array.} modules Modules in execution order. - * @return {Array.} modules - * @return {ast-types.VariableDeclaration} - */ -VariableFormatterBase.prototype.variableDeclaration = function(modules) { - var declarators = []; - - modules.forEach(function(mod) { - if (mod.exports.names.length > 0) { - declarators.push( - b.variableDeclarator(b.identifier(mod.id), b.objectExpression([])) - ); - } - }); - - if (declarators.length > 0) { - return b.variableDeclaration('var', declarators); - } else { - return b.emptyStatement(); - } -}; - -module.exports = VariableFormatterBase; diff --git a/lib/replacement.js b/lib/replacement.js index 8f2aa4e..c4dc8f5 100644 --- a/lib/replacement.js +++ b/lib/replacement.js @@ -1,5 +1,6 @@ /* jshint node:true, undef:true, unused:true */ +var recast = require('recast'); /** * Represents a replacement of a node path with zero or more nodes. * @@ -8,15 +9,30 @@ * @param {Array.} nodes */ function Replacement(nodePath, nodes) { - this.nodePath = nodePath; - this.nodes = nodes; + this.queue = []; + if (nodePath && nodes) { + this.queue.push([nodePath, nodes]); + } } /** * Performs the replacement. */ Replacement.prototype.replace = function() { - this.nodePath.replace.apply(this.nodePath, this.nodes); + for (var i = 0, length = this.queue.length; i < length; i++) { + var item = this.queue[i]; + item[0].replace.apply(item[0], item[1]); + } +}; + +/** + * Incorporates the replacements from the given Replacement into this one. + * + * @param {Replacement} anotherReplacement + */ +Replacement.prototype.and = function(anotherReplacement) { + this.queue.push.apply(this.queue, anotherReplacement.queue); + return this; }; /** @@ -55,4 +71,17 @@ Replacement.swaps = function(nodePath, nodes) { return new Replacement(nodePath, nodes); }; +Replacement.map = function(nodePaths, callback) { + var result = new Replacement(); + + nodePaths.each(function(nodePath) { + var replacement = callback(nodePath); + if (replacement) { + result.and(replacement); + } + }); + + return result; +}; + module.exports = Replacement; diff --git a/lib/rewriter.js b/lib/rewriter.js index 015a297..cad085c 100644 --- a/lib/rewriter.js +++ b/lib/rewriter.js @@ -166,7 +166,13 @@ Rewriter.prototype.getExportReferenceForReference = function(mod, referencePath) } return this.formatter.exportedReference(mod, referencePath) || - this.formatter.importedReference(mod, referencePath); + this.formatter.importedReference(mod, referencePath) || + this.formatter.localReference(mod, referencePath); + + if (!rewrite) { + } + + return rewrite; }; module.exports = Rewriter; diff --git a/package.json b/package.json index 58a529c..84dfb29 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,9 @@ "url": "https://github.com/square/es6-module-transpiler.git" }, "scripts": { - "test": "node test/runner.js -f ${MODULE_FORMATTER:-module-variable}", - "test-module-variable": "node test/runner.js -f module-variable", - "test-export-variable": "node test/runner.js -f export-variable", - "test-commonjs": "node test/runner.js -f commonjs", - "test-all": "npm run test-module-variable && npm run test-export-variable && npm run test-commonjs" + "test": "npm run test-bundle && npm run test-commonjs", + "test-bundle": "node test/runner.js -f bundle", + "test-commonjs": "node test/runner.js -f commonjs" }, "author": "Square, Inc.", "license": "Apache 2", diff --git a/test/examples/module-level-declarations/mod.js b/test/examples/module-level-declarations/mod.js new file mode 100644 index 0000000..6160767 --- /dev/null +++ b/test/examples/module-level-declarations/mod.js @@ -0,0 +1,8 @@ +var a = 1; + +assert.equal(a, 1); +assert.equal(getA(), 1); + +function getA() { + return a; +} \ No newline at end of file