From 9371a6f7098dc224cc015971ff73a74fac7ae912 Mon Sep 17 00:00:00 2001 From: Rich-Harris Date: Fri, 15 May 2015 19:03:59 -0400 Subject: [PATCH] generate UMD output, handle cycles, various fixes --- package.json | 4 +- src/Bundle/index.js | 21 ++- src/Module/index.js | 147 ++++++++++-------- src/ast/analyse.js | 45 ++++-- src/finalisers/amd.js | 3 + src/finalisers/cjs.js | 3 + src/finalisers/es6.js | 3 + src/finalisers/index.js | 6 + src/finalisers/umd.js | 24 +++ src/rollup.js | 7 +- .../import-default-function/_config.js | 3 +- test/samples/try-catch-scoping/_config.js | 3 + test/samples/try-catch-scoping/foo.js | 7 + test/samples/try-catch-scoping/main.js | 2 + 14 files changed, 186 insertions(+), 92 deletions(-) create mode 100644 src/finalisers/amd.js create mode 100644 src/finalisers/cjs.js create mode 100644 src/finalisers/es6.js create mode 100644 src/finalisers/index.js create mode 100644 src/finalisers/umd.js create mode 100644 test/samples/try-catch-scoping/_config.js create mode 100644 test/samples/try-catch-scoping/foo.js create mode 100644 test/samples/try-catch-scoping/main.js diff --git a/package.json b/package.json index 359e4a8..1156d78 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "rollup", "version": "0.1.0", "description": "Next-generation ES6 module bundler", - "main": "dist/index.js", - "jsnext:main": "src/index.js", + "main": "dist/rollup.js", + "jsnext:main": "src/rollup.js", "scripts": { "test": "mocha", "pretest": "npm run build", diff --git a/src/Bundle/index.js b/src/Bundle/index.js index bcec9be..a3ce015 100644 --- a/src/Bundle/index.js +++ b/src/Bundle/index.js @@ -4,6 +4,7 @@ import MagicString from 'magic-string'; import { hasOwnProp } from '../utils/object'; import { sequence } from '../utils/promise'; import Module from '../Module/index'; +import finalisers from '../finalisers/index'; export default class Bundle { constructor ( options ) { @@ -20,6 +21,8 @@ export default class Bundle { // this will store per-module names, and enable deconflicting this.names = {}; this.usedNames = {}; + + this.externalModules = []; } collect () { @@ -71,16 +74,24 @@ export default class Bundle { }); } - generate () { - const bundle = new MagicString.Bundle(); + generate ( options = {} ) { + let magicString = new MagicString.Bundle(); this.body.forEach( statement => { - bundle.addSource( statement._source ); + magicString.addSource( statement._source ); }); + const finalise = finalisers[ options.format || 'es6' ]; + + if ( !finalise ) { + throw new Error( `You must specify an output type - valid options are ${Object.keys( finalisers ).join( ', ' )}` ); + } + + magicString = finalise( this, magicString, options ); + return { - code: bundle.toString(), - map: null // TODO use bundle.generateMap() + code: magicString.toString(), + map: null // TODO use magicString.generateMap() }; // try { diff --git a/src/Module/index.js b/src/Module/index.js index b3f78d6..13e1c04 100644 --- a/src/Module/index.js +++ b/src/Module/index.js @@ -1,14 +1,17 @@ import { dirname, relative, resolve } from 'path'; +import { Promise } from 'sander'; import { parse } from 'acorn'; import MagicString from 'magic-string'; import analyse from '../ast/analyse'; import { hasOwnProp } from '../utils/object'; import { sequence } from '../utils/promise'; +const emptyArrayPromise = Promise.resolve([]); + export default class Module { constructor ({ path, code, bundle }) { this.path = path; - this.relativePath = relative( bundle.base, path ); + this.relativePath = relative( bundle.base, path ).slice( 0, -3 ); // remove .js this.code = new MagicString( code ); this.bundle = bundle; @@ -17,9 +20,12 @@ export default class Module { sourceType: 'module' }); + console.log( '\nanalysing %s\n========', path ); analyse( this.ast, this.code ); + console.log( '========\n\n' ); this.definitions = {}; + this.definitionPromises = {}; this.modifications = {}; this.ast.body.forEach( statement => { @@ -52,20 +58,14 @@ export default class Module { } else if ( node.type === 'ExportDefaultDeclaration' ) { - //const isDeclaration = /Declaration$/.test( node) + const isDeclaration = /Declaration$/.test( node.declaration.type ); + this.exports.default = { node, localName: 'default', - isDeclaration: false, - //expression: node.declaration + name: isDeclaration ? node.declaration.id.name : null, + isDeclaration }; - - // special case - need to transfer top-level node tracking info to expression - // TODO this is fugly, refactor it - // if ( this.exports.default.expression ) { - // this.exports.default.expression._dependsOn = node._dependsOn; - // this.exports.default.expression._source = node._source; - // } } else if ( node.type === 'ExportNamedDeclaration' ) { @@ -103,76 +103,89 @@ export default class Module { }); } - define ( name, importer ) { - let statement; - - // The definition for this name is in a different module - if ( hasOwnProp.call( this.imports, name ) ) { - const importDeclaration = this.imports[ name ]; - const path = resolve( dirname( this.path ), importDeclaration.source ) + '.js'; + define ( name ) { + // shortcut cycles. TODO this won't work everywhere... + if ( hasOwnProp.call( this.definitionPromises, name ) ) { + return emptyArrayPromise; + } - return this.bundle.fetchModule( path ) - .then( module => { - const exportDeclaration = module.exports[ importDeclaration.name ]; + if ( !hasOwnProp.call( this.definitionPromises, name ) ) { + let promise; - if ( !exportDeclaration ) { - throw new Error( `Module ${module.path} does not export ${importDeclaration.name} (imported by ${this.path})` ); - } + // The definition for this name is in a different module + if ( hasOwnProp.call( this.imports, name ) ) { + const importDeclaration = this.imports[ name ]; + const path = resolve( dirname( this.path ), importDeclaration.source ) + '.js'; - // we 'suggest' that the bundle use our local name for this import - // throughout the bundle. If that causes a conflict, we'll end up - // with something slightly different - this.bundle.suggestName( module, exportDeclaration.localName, importDeclaration.localName ); + promise = this.bundle.fetchModule( path ) + .then( module => { + const exportDeclaration = module.exports[ importDeclaration.name ]; + if ( !exportDeclaration ) { + throw new Error( `Module ${module.path} does not export ${importDeclaration.name} (imported by ${this.path})` ); + } - return module.define( exportDeclaration.localName, this ); - }); - } + // we 'suggest' that the bundle use our local name for this import + // throughout the bundle. If that causes a conflict, we'll end up + // with something slightly different + this.bundle.suggestName( module, exportDeclaration.localName, importDeclaration.localName ); - // The definition is in this module; it's the default export - else if ( name === 'default' ) { - const defaultExport = this.exports.default; + return module.define( exportDeclaration.localName ); + }); + } - // We have something like `export default foo` - so we just start again, - // searching for `foo` instead of default - if ( defaultExport.isDeclaration ) { - return this.define( defaultExport.name, this ); + // The definition is in this module + else if ( name === 'default' && this.exports.default.isDeclaration ) { + // We have something like `export default foo` - so we just start again, + // searching for `foo` instead of default + promise = this.define( this.exports.default.name ); } - // Otherwise, we have an expression, e.g. `export default 42`. We have - // to assign that expression to a variable - const name = this.bundle.getName( this, 'default' ); + else { + let statement; - statement = defaultExport.node; - statement._source.overwrite( statement.start, statement.declaration.start, `var ${name} = ` ) - } + if ( name === 'default' ) { + // We have an expression, e.g. `export default 42`. We have + // to assign that expression to a variable + const name = this.bundle.getName( this, 'default' ); - else { - statement = this.definitions[ name ]; + statement = this.exports.default.node; - if ( /^Export/.test( statement.type ) ) { - statement._source.remove( statement.start, statement.declaration.start ); - } - } + if ( !statement._imported ) { + statement._source.overwrite( statement.start, statement.declaration.start, `var ${name} = ` ) + } + } + else { + statement = this.definitions[ name ]; - if ( statement ) { - const nodes = []; - - return sequence( Object.keys( statement._dependsOn ), name => { - return this.define( name, this ); - }) - .then( definitions => { - definitions.forEach( definition => nodes.push.apply( nodes, definition ) ); - }) - .then( () => { - nodes.push( statement ); - }) - .then( () => { - return nodes; - }); - } else { - throw new Error( `Could not define ${name}` ); + if ( statement && /^Export/.test( statement.type ) ) { + statement._source.remove( statement.start, statement.declaration.start ); + } + } + + if ( statement && !statement._imported ) { + const nodes = []; + + promise = sequence( Object.keys( statement._dependsOn ), name => { + return this.define( name ); + }) + .then( definitions => { + definitions.forEach( definition => nodes.push.apply( nodes, definition ) ); + }) + .then( () => { + statement._imported = true; + nodes.push( statement ); + }) + .then( () => { + return nodes; + }); + } + } + + this.definitionPromises[ name ] = promise || emptyArrayPromise; } + + return this.definitionPromises[ name ]; } } \ No newline at end of file diff --git a/src/ast/analyse.js b/src/ast/analyse.js index 35749c2..fa80fe3 100644 --- a/src/ast/analyse.js +++ b/src/ast/analyse.js @@ -41,6 +41,7 @@ export default function analyse ( ast, code ) { statement._defines = {}; statement._modifies = {}; statement._dependsOn = {}; + statement._imported = false; // store the actual code, for easy regeneration statement._source = code.snip( previous, statement.end ); @@ -50,33 +51,42 @@ export default function analyse ( ast, code ) { walk( statement, { enter ( node, parent ) { + let newScope; + switch ( node.type ) { case 'FunctionExpression': case 'FunctionDeclaration': case 'ArrowFunctionExpression': - if ( node.id ) { + let names = node.params.map( getName ); + + if ( node.type === 'FunctionDeclaration' ) { addToScope( node ); + } else if ( node.type === 'FunctionExpression' && node.id ) { + names.push( node.id.name ); } - let names = node.params.map( getName ); - - scope = new Scope({ + newScope = new Scope({ parent: scope, params: names, // TODO rest params? block: false }); - Object.defineProperty( node, '_scope', { value: scope }); - break; case 'BlockStatement': - scope = new Scope({ + newScope = new Scope({ parent: scope, block: true }); - Object.defineProperty( node, '_scope', { value: scope }); + break; + + case 'CatchClause': + newScope = new Scope({ + parent: scope, + params: [ node.param.name ], + block: true + }); break; @@ -89,19 +99,19 @@ export default function analyse ( ast, code ) { addToScope( node ); break; } + + if ( newScope ) { + Object.defineProperty( node, '_scope', { value: newScope }); + scope = newScope; + } }, leave ( node ) { if ( node === currentTopLevelStatement ) { currentTopLevelStatement = null; } - switch ( node.type ) { - case 'FunctionExpression': - case 'FunctionDeclaration': - case 'ArrowFunctionExpression': - case 'BlockStatement': - scope = scope.parent; - break; + if ( node._scope ) { + scope = scope.parent; } } }); @@ -112,6 +122,11 @@ export default function analyse ( ast, code ) { ast.body.forEach( statement => { function checkForReads ( node, parent ) { if ( node.type === 'Identifier' ) { + // disregard the `bar` in `foo.bar` - these appear as Identifier nodes + if ( parent.type === 'MemberExpression' && node !== parent.object ) { + return; + } + const definingScope = scope.findDefiningScope( node.name ); if ( ( !definingScope || definingScope.depth === 0 ) && !statement._defines[ node.name ] ) { diff --git a/src/finalisers/amd.js b/src/finalisers/amd.js new file mode 100644 index 0000000..3e4d3cb --- /dev/null +++ b/src/finalisers/amd.js @@ -0,0 +1,3 @@ +export default function amd ( bundle, magicString, options ) { + throw new Error( 'TODO' ); +} \ No newline at end of file diff --git a/src/finalisers/cjs.js b/src/finalisers/cjs.js new file mode 100644 index 0000000..c90f559 --- /dev/null +++ b/src/finalisers/cjs.js @@ -0,0 +1,3 @@ +export default function cjs ( bundle, magicString, options ) { + return magicString; +} \ No newline at end of file diff --git a/src/finalisers/es6.js b/src/finalisers/es6.js new file mode 100644 index 0000000..fb9ba34 --- /dev/null +++ b/src/finalisers/es6.js @@ -0,0 +1,3 @@ +export default function es6 ( bundle, magicString, options ) { + throw new Error( 'TODO' ); +} \ No newline at end of file diff --git a/src/finalisers/index.js b/src/finalisers/index.js new file mode 100644 index 0000000..d3bf38d --- /dev/null +++ b/src/finalisers/index.js @@ -0,0 +1,6 @@ +import amd from './amd'; +import cjs from './cjs'; +import es6 from './es6'; +import umd from './umd'; + +export default { amd, cjs, es6, umd }; \ No newline at end of file diff --git a/src/finalisers/umd.js b/src/finalisers/umd.js new file mode 100644 index 0000000..053fb64 --- /dev/null +++ b/src/finalisers/umd.js @@ -0,0 +1,24 @@ +export default function umd ( bundle, magicString, options ) { + const indentStr = magicString.getIndentString(); + + const intro = + `(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(factory) : + factory((global.${options.globalName} = {})); + }(this, function (exports) { 'use strict'; + + `.replace( /^\t\t/gm, '' ).replace( /^\t/g, indentStr ); + + const exports = bundle.entryModule.exports; + + const exportBlock = '\n\n' + Object.keys( exports ).map( name => { + return `exports.${name} = ${exports[name].localName};` + }).join( '\n' ); + + return magicString + .append( exportBlock ) + .indent() + .append( '\n\n}));' ) + .prepend( intro ); +} \ No newline at end of file diff --git a/src/rollup.js b/src/rollup.js index 0ee28d5..1e8591a 100644 --- a/src/rollup.js +++ b/src/rollup.js @@ -1,3 +1,4 @@ +import { writeFile } from 'sander'; import Bundle from './Bundle'; export function rollup ( entry, options = {} ) { @@ -9,8 +10,10 @@ export function rollup ( entry, options = {} ) { return bundle.collect().then( () => { return { generate: options => bundle.generate( options ), - write: () => { - throw new Error( 'TODO' ); + write: ( dest, options ) => { + const generated = bundle.generate( options ); + + return writeFile( dest, generated.code ); } }; }); diff --git a/test/samples/import-default-function/_config.js b/test/samples/import-default-function/_config.js index e0695e2..876a55c 100644 --- a/test/samples/import-default-function/_config.js +++ b/test/samples/import-default-function/_config.js @@ -1,3 +1,4 @@ module.exports = { - description: 'imports a default function' + description: 'imports a default function', + // solo: true }; \ No newline at end of file diff --git a/test/samples/try-catch-scoping/_config.js b/test/samples/try-catch-scoping/_config.js new file mode 100644 index 0000000..4e124b5 --- /dev/null +++ b/test/samples/try-catch-scoping/_config.js @@ -0,0 +1,3 @@ +module.exports = { + description: 'error parameter in catch clause is correctly scoped' +}; \ No newline at end of file diff --git a/test/samples/try-catch-scoping/foo.js b/test/samples/try-catch-scoping/foo.js new file mode 100644 index 0000000..160e8ea --- /dev/null +++ b/test/samples/try-catch-scoping/foo.js @@ -0,0 +1,7 @@ +export default function foo () { + try { + return 42; + } catch ( err ) { + console.log( err ); + } +} \ No newline at end of file diff --git a/test/samples/try-catch-scoping/main.js b/test/samples/try-catch-scoping/main.js new file mode 100644 index 0000000..bd938fa --- /dev/null +++ b/test/samples/try-catch-scoping/main.js @@ -0,0 +1,2 @@ +import foo from './foo'; +assert.equal( foo(), 42 ); \ No newline at end of file