From 9435773b1ccf84f048ae07ef29ec520a235b1c67 Mon Sep 17 00:00:00 2001 From: Rich-Harris Date: Sat, 25 Jul 2015 12:56:26 -0400 Subject: [PATCH] start moving render logic into modules --- src/Bundle.js | 157 ++++++++++++++++++++++++++++++----------- src/Module.js | 177 ++++++++++++++++++++++++++++++++++++++++++----- src/Statement.js | 18 ++--- src/rollup.js | 4 +- 4 files changed, 283 insertions(+), 73 deletions(-) diff --git a/src/Bundle.js b/src/Bundle.js index 4ed55a0..452b3d0 100644 --- a/src/Bundle.js +++ b/src/Bundle.js @@ -13,15 +13,6 @@ import getExportMode from './utils/getExportMode'; import getIndentString from './utils/getIndentString'; import { unixizePath } from './utils/normalizePlatform.js'; -function isEmptyExportedVarDeclaration ( node, module, allBundleExports, es6 ) { - if ( node.type !== 'VariableDeclaration' || node.declarations[0].init ) return false; - - const name = node.declarations[0].id.name; - const canonicalName = module.getCanonicalName( name, es6 ); - - return canonicalName in allBundleExports; -} - export default class Bundle { constructor ( options ) { this.entry = options.entry; @@ -90,7 +81,7 @@ export default class Bundle { return this.markAllModifierStatements(); }) .then( () => { - this.statements = this.sort(); + this.orderedModules = this.sort(); }); } @@ -115,34 +106,35 @@ export default class Bundle { }); // Discover conflicts (i.e. two statements in separate modules both define `foo`) - this.statements.forEach( statement => { - const module = statement.module; - const names = keys( statement.defines ); + this.orderedModules.forEach( module => { + module.statements.forEach( statement => { + const names = keys( statement.defines ); - // with default exports that are expressions (`export default 42`), - // we need to ensure that the name chosen for the expression does - // not conflict - if ( statement.node.type === 'ExportDefaultDeclaration' ) { - const name = module.getCanonicalName( 'default', es6 ); + // with default exports that are expressions (`export default 42`), + // we need to ensure that the name chosen for the expression does + // not conflict + if ( statement.node.type === 'ExportDefaultDeclaration' ) { + const name = module.getCanonicalName( 'default', es6 ); - const isProxy = statement.node.declaration && statement.node.declaration.type === 'Identifier'; - const shouldDeconflict = !isProxy || ( module.getCanonicalName( statement.node.declaration.name, es6 ) !== name ); + const isProxy = statement.node.declaration && statement.node.declaration.type === 'Identifier'; + const shouldDeconflict = !isProxy || ( module.getCanonicalName( statement.node.declaration.name, es6 ) !== name ); - if ( shouldDeconflict && !~names.indexOf( name ) ) { - names.push( name ); + if ( shouldDeconflict && !~names.indexOf( name ) ) { + names.push( name ); + } } - } - names.forEach( name => { - if ( definers[ name ] ) { - conflicts[ name ] = true; - } else { - definers[ name ] = []; - } + names.forEach( name => { + if ( definers[ name ] ) { + conflicts[ name ] = true; + } else { + definers[ name ] = []; + } - // TODO in good js, there shouldn't be duplicate definitions - // per module... but some people write bad js - definers[ name ].push( module ); + // TODO in good js, there shouldn't be duplicate definitions + // per module... but some people write bad js + definers[ name ].push( module ); + }); }); }); @@ -459,6 +451,97 @@ export default class Bundle { }); } + render ( options = {} ) { + const format = options.format || 'es6'; + this.deconflict( format === 'es6' ); + + // If we have named exports from the bundle, and those exports + // are assigned to *within* the bundle, we may need to rewrite e.g. + // + // export let count = 0; + // export function incr () { count++ } + // + // might become... + // + // exports.count = 0; + // function incr () { + // exports.count += 1; + // } + // exports.incr = incr; + // + // This doesn't apply if the bundle is exported as ES6! + let allBundleExports = blank(); + + if ( format !== 'es6' ) { + keys( this.entryModule.exports ).forEach( key => { + const exportDeclaration = this.entryModule.exports[ key ]; + + const originalDeclaration = this.entryModule.findDeclaration( exportDeclaration.localName ); + + if ( originalDeclaration && originalDeclaration.type === 'VariableDeclaration' ) { + const canonicalName = this.entryModule.getCanonicalName( exportDeclaration.localName, false ); + + allBundleExports[ canonicalName ] = `exports.${key}`; + this.varExports[ key ] = true; + } + }); + } + + // since we're rewriting variable exports, we want to + // ensure we don't try and export them again at the bottom + this.toExport = keys( this.entryModule.exports ) + .filter( key => !this.varExports[ key ] ); + + + let magicString = new MagicString.Bundle({ separator: '\n\n' }); + + this.orderedModules.forEach( module => { + magicString.addSource( module.render( allBundleExports, format ) ); + }); + + // prepend bundle with internal namespaces + const indentString = magicString.getIndentString(); + const namespaceBlock = this.internalNamespaceModules.map( module => { + const exportKeys = keys( module.exports ); + + return `var ${module.getCanonicalName('*', format === 'es6')} = {\n` + + exportKeys.map( key => `${indentString}get ${key} () { return ${module.getCanonicalName(key, format === 'es6')}; }` ).join( ',\n' ) + + `\n};\n\n`; + }).join( '' ); + + magicString.prepend( namespaceBlock ); + + const finalise = finalisers[ format ]; + + if ( !finalise ) { + throw new Error( `You must specify an output type - valid options are ${keys( finalisers ).join( ', ' )}` ); + } + + magicString = finalise( this, magicString.trim(), { + // Determine export mode - 'default', 'named', 'none' + exportMode: getExportMode( this, options.exports ), + + // Determine indentation + indentString: getIndentString( magicString, options ) + }, options ); + + const code = magicString.toString(); + let map = null; + + if ( options.sourceMap ) { + const file = options.sourceMapFile || options.dest; + map = magicString.generateMap({ + includeContent: true, + file + // TODO + }); + + map.sources = map.sources.map( unixizePath ); + } + + return { code, map }; + } + sort () { let seen = {}; let ordered = []; @@ -540,14 +623,6 @@ export default class Bundle { }); } - let statements = []; - - ordered.forEach( module => { - module.statements.forEach( statement => { - if ( statement.isIncluded ) statements.push( statement ); - }); - }); - - return statements; + return ordered; } } diff --git a/src/Module.js b/src/Module.js index a26e340..907e540 100644 --- a/src/Module.js +++ b/src/Module.js @@ -21,6 +21,15 @@ function deconflict ( name, names ) { return name; } +function isEmptyExportedVarDeclaration ( node, module, allBundleExports, es6 ) { + if ( node.type !== 'VariableDeclaration' || node.declarations[0].init ) return false; + + const name = node.declarations[0].id.name; + const canonicalName = module.getCanonicalName( name, es6 ); + + return canonicalName in allBundleExports; +} + export default class Module { constructor ({ id, source, bundle }) { this.source = source; @@ -531,36 +540,41 @@ export default class Module { let statements = []; - ast.body.map( node => { + ast.body.forEach( node => { // special case - top-level var declarations with multiple declarators // should be split up. Otherwise, we may end up including code we // don't need, just because an unwanted declarator is included if ( node.type === 'VariableDeclaration' && node.declarations.length > 1 ) { - node.declarations.forEach( declarator => { - const magicString = this.magicString.snip( declarator.start, declarator.end ).trim(); - magicString.prepend( `${node.kind} ` ).append( ';' ); - - const syntheticNode = { - type: 'VariableDeclaration', - kind: node.kind, - start: node.start, - end: node.end, - declarations: [ declarator ] - }; - - const statement = new Statement( syntheticNode, magicString, this, statements.length ); - statements.push( statement ); - }); + throw new Error( 'TODO' ); + // node.declarations.forEach( declarator => { + // const magicString = this.magicString.snip( declarator.start, declarator.end ).trim(); + // magicString.prepend( `${node.kind} ` ).append( ';' ); + // + // const syntheticNode = { + // type: 'VariableDeclaration', + // kind: node.kind, + // start: node.start, + // end: node.end, + // declarations: [ declarator ] + // }; + // + // const statement = new Statement( syntheticNode, magicString, this, statements.length ); + // statements.push( statement ); + // }); } else { - const magicString = this.magicString.snip( node.start, node.end ).trim(); - const statement = new Statement( node, magicString, this, statements.length ); + const statement = new Statement( node, this, node.start, node.end ); // TODO should be comment start, comment end statements.push( statement ); } }); + statements.forEach( ( statement, i ) => { + const nextStatement = statements[ i + 1 ]; + statement.next = nextStatement ? nextStatement.start : statement.end; + }); + return statements; } @@ -569,6 +583,133 @@ export default class Module { this.canonicalNames[ name ] = this.canonicalNames[ name + '-es6' ] = replacement; } + render ( allBundleExports, format ) { + let magicString = this.magicString.clone(); + + let previousIndex = -1; + let previousMargin = 0; + + this.statements.forEach( statement => { + if ( !statement.isIncluded ) { + magicString.remove( statement.start, statement.next ); + return; + } + + // skip `export { foo, bar, baz }` + if ( statement.node.type === 'ExportNamedDeclaration' ) { + // skip `export { foo, bar, baz }` + if ( statement.node.specifiers.length ) { + magicString.remove( statement.start, statement.next ); + return; + }; + + // skip `export var foo;` if foo is exported + if ( isEmptyExportedVarDeclaration( statement.node.declaration, statement.module, allBundleExports, format === 'es6' ) ) { + magicString.remove( statement.start, statement.next ); + return; + } + } + + // skip empty var declarations for exported bindings + // (otherwise we're left with `exports.foo;`, which is useless) + if ( isEmptyExportedVarDeclaration( statement.node, statement.module, allBundleExports, format === 'es6' ) ) { + magicString.remove( statement.start, statement.next ); + return; + } + + let replacements = blank(); + let bundleExports = blank(); + + keys( statement.dependsOn ) + .concat( keys( statement.defines ) ) + .forEach( name => { + const canonicalName = statement.module.getCanonicalName( name, format === 'es6' ); + + if ( allBundleExports[ canonicalName ] ) { + bundleExports[ name ] = replacements[ name ] = allBundleExports[ canonicalName ]; + } else if ( name !== canonicalName ) { + replacements[ name ] = canonicalName; + } + }); + + statement.replaceIdentifiers( magicString, replacements, bundleExports ); + + // modify exports as necessary + if ( statement.isExportDeclaration ) { + // remove `export` from `export var foo = 42` + if ( statement.node.type === 'ExportNamedDeclaration' && statement.node.declaration.type === 'VariableDeclaration' ) { + magicString.remove( statement.node.start, statement.node.declaration.start ); + } + + // remove `export` from `export class Foo {...}` or `export default Foo` + // TODO default exports need different treatment + else if ( statement.node.declaration.id ) { + magicString.remove( statement.node.start, statement.node.declaration.start ); + } + + else if ( statement.node.type === 'ExportDefaultDeclaration' ) { + const module = statement.module; + const canonicalName = module.getCanonicalName( 'default', format === 'es6' ); + + if ( statement.node.declaration.type === 'Identifier' && canonicalName === module.getCanonicalName( statement.node.declaration.name, format === 'es6' ) ) { + magicString.remove( statement.start, statement.next ); + return; + } + + // anonymous functions should be converted into declarations + if ( statement.node.declaration.type === 'FunctionExpression' ) { + magicString.overwrite( statement.node.start, statement.node.declaration.start + 8, `function ${canonicalName}` ); + } else { + magicString.overwrite( statement.node.start, statement.node.declaration.start, `var ${canonicalName} = ` ); + } + } + + else { + throw new Error( 'Unhandled export' ); + } + } + + // // ensure there is always a newline between statements, and add + // // additional newlines as necessary to reflect original source + // const minSeparation = ( statement.index !== previousIndex + 1 ) ? 3 : 2; + // const margin = Math.max( minSeparation, statement.margin[0], previousMargin ); + // let newLines = new Array( margin ).join( '\n' ); + // + // // add leading comments + // if ( statement.leadingComments.length ) { + // const commentBlock = newLines + statement.leadingComments.map( ({ separator, comment }) => { + // return separator + ( comment.block ? + // `/*${comment.text}*/` : + // `//${comment.text}` ); + // }).join( '' ); + // + // magicString.addSource( new MagicString( commentBlock ) ); + // newLines = new Array( statement.margin[0] ).join( '\n' ); // TODO handle gaps between comment block and statement + // } + // + // // add the statement itself + // magicString.addSource({ + // content: source, + // separator: newLines + // }); + // + // // add trailing comments + // const comment = statement.trailingComment; + // if ( comment ) { + // const commentBlock = comment.block ? + // ` /*${comment.text}*/` : + // ` //${comment.text}`; + // + // magicString.append( commentBlock ); + // } + // + // previousMargin = statement.margin[1]; + // previousIndex = statement.index; + }); + + return magicString; + } + suggestName ( defaultOrBatch, suggestion ) { // deconflict anonymous default exports with this module's definitions const shouldDeconflict = this.exports.default && this.exports.default.isAnonymous; diff --git a/src/Statement.js b/src/Statement.js index 7cfbebf..9012d46 100644 --- a/src/Statement.js +++ b/src/Statement.js @@ -9,12 +9,12 @@ function isIife ( node, parent ) { } export default class Statement { - constructor ( node, magicString, module, index ) { + constructor ( node, module, start, end ) { this.node = node; this.module = module; - this.magicString = magicString; - this.index = index; - this.id = module.id + '#' + index; + this.start = start; + this.end = end; + this.next = null; // filled in later this.scope = new Scope(); this.defines = blank(); @@ -37,17 +37,12 @@ export default class Statement { analyse () { if ( this.isImportDeclaration ) return; // nothing to analyse - const statement = this; // TODO use arrow functions instead - const magicString = this.magicString; - let scope = this.scope; walk( this.node, { enter ( node, parent ) { let newScope; - magicString.addSourcemapLocation( node.start ); - switch ( node.type ) { case 'FunctionExpression': case 'FunctionDeclaration': @@ -146,7 +141,7 @@ export default class Statement { } keys( scope.declarations ).forEach( name => { - statement.defines[ name ] = true; + this.defines[ name ] = true; }); } @@ -247,8 +242,7 @@ export default class Statement { }); } - replaceIdentifiers ( names, bundleExports ) { - const magicString = this.magicString.clone(); + replaceIdentifiers ( magicString, names, bundleExports ) { const replacementStack = [ names ]; const nameList = keys( names ); diff --git a/src/rollup.js b/src/rollup.js index 0b8a652..846c8f2 100644 --- a/src/rollup.js +++ b/src/rollup.js @@ -14,14 +14,14 @@ export function rollup ( options ) { return bundle.build().then( () => { return { - generate: options => bundle.generate( options ), + generate: options => bundle.render( options ), write: options => { if ( !options || !options.dest ) { throw new Error( 'You must supply options.dest to bundle.write' ); } const dest = options.dest; - let { code, map } = bundle.generate( options ); + let { code, map } = bundle.render( options ); let promises = [];