import { resolve } from 'path'; import { readFile } from 'sander'; import MagicString from 'magic-string'; import { keys, has } from './utils/object'; import { sequence } from './utils/promise'; import Module from './Module'; import ExternalModule from './ExternalModule'; import finalisers from './finalisers/index'; import replaceIdentifiers from './utils/replaceIdentifiers'; import makeLegalIdentifier from './utils/makeLegalIdentifier'; import { defaultResolver } from './utils/resolvePath'; export default class Bundle { constructor ( options ) { this.base = options.base || process.cwd(); this.entryPath = resolve( this.base, options.entry ).replace( /\.js$/, '' ) + '.js'; this.entryModule = null; this.resolvePath = options.resolvePath || defaultResolver; this.modulePromises = {}; this.modules = {}; // this will store the top-level AST nodes we import this.body = []; // this will store per-module names, and enable deconflicting this.bindingNames = {}; this.usedNames = {}; this.externalModules = []; } fetchModule ( importee, importer ) { const path = this.resolvePath( importee, importer ); if ( !path ) { // external module if ( !has( this.modulePromises, importee ) ) { const module = new ExternalModule( importee ); this.externalModules.push( module ); this.modulePromises[ importee ] = Promise.resolve( module ); } return this.modulePromises[ importee ]; } if ( !has( this.modulePromises, path ) ) { this.modulePromises[ path ] = readFile( path, { encoding: 'utf-8' }) .then( code => { const module = new Module({ path, code, bundle: this }); this.modules[ path ] = module; return module; }); } return this.modulePromises[ path ]; } build () { // bring in top-level AST nodes from the entry module return this.fetchModule( this.entryPath ) .then( entryModule => { this.entryModule = entryModule; const importedNames = keys( entryModule.imports ); entryModule.definedNames .concat( importedNames ) .forEach( name => { this.usedNames[ name ] = true; }); // pull in imports return sequence( importedNames, name => { return entryModule.define( name ) .then( nodes => { this.body.push.apply( this.body, nodes ); }); }) .then( () => { entryModule.ast.body.forEach( node => { // exclude imports and exports, include everything else if ( !/^(?:Im|Ex)port/.test( node.type ) ) { this.body.push( node ); } }); }); }) .then( () => { this.deconflict(); }); } deconflict () { // TODO this probably needs to happen at generate time, since // treatment of external modules differs between formats // e.g. this... // // import { relative } from 'path'`; // console.log( relative( 'foo', 'bar' ) ); // // ...would look very similar when bundled as ES6, but in // a CommonJS bundle would become this: // // var path = require( 'path' ); // console.log( path.relative( 'foo', 'bar' ) ); let definers = {}; let conflicts = {}; // Discover conflicts (i.e. two statements in separate modules both define `foo`) this.body.forEach( statement => { keys( statement._defines ).forEach( name => { if ( has( 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( statement._module ); }); }); // Assign names to external modules this.externalModules.forEach( module => { let name = makeLegalIdentifier( module.id ); while ( has( definers, name ) ) { name = `_${name}`; } module.name = name; }); // Rename conflicting identifiers so they can live in the same scope keys( conflicts ).forEach( name => { const modules = definers[ name ]; modules.pop(); // the module closest to the entryModule gets away with keeping things as they are modules.forEach( module => { module.rename( name, name + '$' + ~~( Math.random() * 100000 ) ); // TODO proper deconfliction mechanism }); }); } generate ( options = {} ) { let magicString = new MagicString.Bundle({ separator: '' }); // TODO we shouldn't be adding export statements back into the entry // module, they shouldn't be removed in the first place this.entryModule.exportStatements.forEach( statement => { if ( statement.specifiers.length ) { // we don't need to include `export { foo }`, it's already handled return; } if ( statement.declaration.type === 'VariableDeclaration' ) { statement._source.remove( statement.start, statement.declaration.start ); } else { // TODO function, class declarations } this.body.push( statement ); }); // Apply new names and add to the output bundle this.body.forEach( statement => { let replacements = {}; keys( statement._dependsOn ) .concat( keys( statement._defines ) ) .forEach( name => { const canonicalName = statement._module.getCanonicalName( name ); if ( name !== canonicalName ) { replacements[ name ] = canonicalName; } }); const source = statement._source.clone(); replaceIdentifiers( statement, source, replacements ); magicString.addSource( source ); }); const finalise = finalisers[ options.format || 'es6' ]; if ( !finalise ) { throw new Error( `You must specify an output type - valid options are ${keys( finalisers ).join( ', ' )}` ); } magicString = finalise( this, magicString, options ); return { code: magicString.toString(), map: magicString.generateMap({ includeContent: true, file: options.dest // TODO }) }; } }