UNPKG

12.2 kBJavaScriptView Raw
1import { basename, dirname, extname, relative, resolve } from 'path';
2import { readFile, Promise } from 'sander';
3import MagicString from 'magic-string';
4import { blank, keys } from './utils/object';
5import Module from './Module';
6import ExternalModule from './ExternalModule';
7import finalisers from './finalisers/index';
8import makeLegalIdentifier from './utils/makeLegalIdentifier';
9import ensureArray from './utils/ensureArray';
10import { defaultResolver, defaultExternalResolver } from './utils/resolvePath';
11import { defaultLoader } from './utils/load';
12import getExportMode from './utils/getExportMode';
13import getIndentString from './utils/getIndentString';
14import { unixizePath } from './utils/normalizePlatform.js';
15
16function badExports ( option, keys ) {
17 throw new Error( `'${option}' was specified for options.exports, but entry module has following exports: ${keys.join(', ')}` );
18}
19
20export default class Bundle {
21 constructor ( options ) {
22 this.entryPath = resolve( options.entry ).replace( /\.js$/, '' ) + '.js';
23 this.base = dirname( this.entryPath );
24
25 this.resolvePath = options.resolvePath || defaultResolver;
26 this.load = options.load || defaultLoader;
27
28 this.resolvePathOptions = {
29 external: ensureArray( options.external ),
30 resolveExternal: options.resolveExternal || defaultExternalResolver
31 };
32
33 this.loadOptions = {
34 transform: ensureArray( options.transform )
35 };
36
37 this.entryModule = null;
38
39 this.varExports = blank();
40 this.toExport = null;
41
42 this.modulePromises = blank();
43 this.statements = [];
44 this.externalModules = [];
45 this.internalNamespaceModules = [];
46 this.assumedGlobals = blank();
47 }
48
49 fetchModule ( importee, importer ) {
50 return Promise.resolve( importer === null ? importee : this.resolvePath( importee, importer, this.resolvePathOptions ) )
51 .then( path => {
52 if ( !path ) {
53 // external module
54 if ( !this.modulePromises[ importee ] ) {
55 const module = new ExternalModule( importee );
56 this.externalModules.push( module );
57 this.modulePromises[ importee ] = Promise.resolve( module );
58 }
59
60 return this.modulePromises[ importee ];
61 }
62
63 if ( !this.modulePromises[ path ] ) {
64 this.modulePromises[ path ] = Promise.resolve( this.load( path, this.loadOptions ) )
65 .then( source => {
66 const module = new Module({
67 path,
68 source,
69 bundle: this
70 });
71
72 return module;
73 });
74 }
75
76 return this.modulePromises[ path ];
77 });
78 }
79
80 build () {
81 // bring in top-level AST nodes from the entry module
82 return this.fetchModule( this.entryPath, null )
83 .then( entryModule => {
84 const defaultExport = entryModule.exports.default;
85
86 this.entryModule = entryModule;
87
88 if ( defaultExport ) {
89 // `export default function foo () {...}` -
90 // use the declared name for the export
91 if ( defaultExport.declaredName ) {
92 entryModule.suggestName( 'default', defaultExport.declaredName );
93 }
94
95 // `export default a + b` - generate an export name
96 // based on the filename of the entry module
97 else {
98 let defaultExportName = makeLegalIdentifier( basename( this.entryPath ).slice( 0, -extname( this.entryPath ).length ) );
99
100 // deconflict
101 let topLevelNames = [];
102 entryModule.statements.forEach( statement => {
103 keys( statement.defines ).forEach( name => topLevelNames.push( name ) );
104 });
105
106 while ( ~topLevelNames.indexOf( defaultExportName ) ) {
107 defaultExportName = `_${defaultExportName}`;
108 }
109
110 entryModule.suggestName( 'default', defaultExportName );
111 }
112 }
113
114 return entryModule.expandAllStatements( true );
115 })
116 .then( statements => {
117 this.statements = statements;
118 this.deconflict();
119 });
120 }
121
122 deconflict () {
123 let definers = blank();
124 let conflicts = blank();
125
126 // Discover conflicts (i.e. two statements in separate modules both define `foo`)
127 this.statements.forEach( statement => {
128 const module = statement.module;
129 const names = keys( statement.defines );
130
131 // with default exports that are expressions (`export default 42`),
132 // we need to ensure that the name chosen for the expression does
133 // not conflict
134 if ( statement.node.type === 'ExportDefaultDeclaration' ) {
135 const name = module.getCanonicalName( 'default' );
136
137 const isProxy = statement.node.declaration && statement.node.declaration.type === 'Identifier';
138 const shouldDeconflict = !isProxy || ( module.getCanonicalName( statement.node.declaration.name ) !== name );
139
140 if ( shouldDeconflict && !~names.indexOf( name ) ) {
141 names.push( name );
142 }
143 }
144
145 names.forEach( name => {
146 if ( definers[ name ] ) {
147 conflicts[ name ] = true;
148 } else {
149 definers[ name ] = [];
150 }
151
152 // TODO in good js, there shouldn't be duplicate definitions
153 // per module... but some people write bad js
154 definers[ name ].push( module );
155 });
156 });
157
158 // Assign names to external modules
159 this.externalModules.forEach( module => {
160 // TODO is this right?
161 let name = makeLegalIdentifier( module.suggestedNames['*'] || module.suggestedNames.default || module.id );
162
163 if ( definers[ name ] ) {
164 conflicts[ name ] = true;
165 } else {
166 definers[ name ] = [];
167 }
168
169 definers[ name ].push( module );
170 module.name = name;
171 });
172
173 // Ensure we don't conflict with globals
174 keys( this.assumedGlobals ).forEach( name => {
175 if ( definers[ name ] ) {
176 conflicts[ name ] = true;
177 }
178 });
179
180 // Rename conflicting identifiers so they can live in the same scope
181 keys( conflicts ).forEach( name => {
182 const modules = definers[ name ];
183
184 if ( !this.assumedGlobals[ name ] ) {
185 // the module closest to the entryModule gets away with
186 // keeping things as they are, unless we have a conflict
187 // with a global name
188 modules.pop();
189 }
190
191 modules.forEach( module => {
192 const replacement = getSafeName( name );
193 module.rename( name, replacement );
194 });
195 });
196
197 function getSafeName ( name ) {
198 while ( conflicts[ name ] ) {
199 name = `_${name}`;
200 }
201
202 conflicts[ name ] = true;
203 return name;
204 }
205 }
206
207 generate ( options = {} ) {
208 let magicString = new MagicString.Bundle({ separator: '' });
209
210 const format = options.format || 'es6';
211
212 // If we have named exports from the bundle, and those exports
213 // are assigned to *within* the bundle, we may need to rewrite e.g.
214 //
215 // export let count = 0;
216 // export function incr () { count++ }
217 //
218 // might become...
219 //
220 // exports.count = 0;
221 // function incr () {
222 // exports.count += 1;
223 // }
224 // exports.incr = incr;
225 //
226 // This doesn't apply if the bundle is exported as ES6!
227 let allBundleExports = blank();
228
229 if ( format !== 'es6' ) {
230 keys( this.entryModule.exports ).forEach( key => {
231 const exportDeclaration = this.entryModule.exports[ key ];
232
233 const originalDeclaration = this.entryModule.findDeclaration( exportDeclaration.localName );
234
235 if ( originalDeclaration && originalDeclaration.type === 'VariableDeclaration' ) {
236 const canonicalName = this.entryModule.getCanonicalName( exportDeclaration.localName );
237
238 allBundleExports[ canonicalName ] = `exports.${key}`;
239 this.varExports[ key ] = true;
240 }
241 });
242 }
243
244 // since we're rewriting variable exports, we want to
245 // ensure we don't try and export them again at the bottom
246 this.toExport = keys( this.entryModule.exports )
247 .filter( key => !this.varExports[ key ] );
248
249 // Apply new names and add to the output bundle
250 let previousModule = null;
251 let previousIndex = -1;
252 let previousMargin = 0;
253
254 this.statements.forEach( statement => {
255 // skip `export { foo, bar, baz }`
256 if ( statement.node.type === 'ExportNamedDeclaration' && statement.node.specifiers.length ) {
257 return;
258 }
259
260 let replacements = blank();
261 let bundleExports = blank();
262
263 keys( statement.dependsOn )
264 .concat( keys( statement.defines ) )
265 .forEach( name => {
266 const canonicalName = statement.module.getCanonicalName( name );
267
268 if ( allBundleExports[ canonicalName ] ) {
269 bundleExports[ name ] = replacements[ name ] = allBundleExports[ canonicalName ];
270 } else if ( name !== canonicalName ) {
271 replacements[ name ] = canonicalName;
272 }
273 });
274
275 const source = statement.replaceIdentifiers( replacements, bundleExports );
276
277 // modify exports as necessary
278 if ( statement.isExportDeclaration ) {
279 // remove `export` from `export var foo = 42`
280 if ( statement.node.type === 'ExportNamedDeclaration' && statement.node.declaration.type === 'VariableDeclaration' ) {
281 source.remove( statement.node.start, statement.node.declaration.start );
282 }
283
284 // remove `export` from `export class Foo {...}` or `export default Foo`
285 // TODO default exports need different treatment
286 else if ( statement.node.declaration.id ) {
287 source.remove( statement.node.start, statement.node.declaration.start );
288 }
289
290 else if ( statement.node.type === 'ExportDefaultDeclaration' ) {
291 const module = statement.module;
292 const canonicalName = module.getCanonicalName( 'default' );
293
294 if ( statement.node.declaration.type === 'Identifier' && canonicalName === module.getCanonicalName( statement.node.declaration.name ) ) {
295 return;
296 }
297
298 source.overwrite( statement.node.start, statement.node.declaration.start, `var ${canonicalName} = ` );
299 }
300
301 else {
302 throw new Error( 'Unhandled export' );
303 }
304 }
305
306 // ensure there is always a newline between statements, and add
307 // additional newlines as necessary to reflect original source
308 const minSeparation = ( previousModule !== statement.module ) || ( statement.index !== previousIndex + 1 ) ? 3 : 2;
309 const margin = Math.max( minSeparation, statement.margin[0], previousMargin );
310 let newLines = new Array( margin ).join( '\n' );
311
312 // add leading comments
313 if ( statement.leadingComments.length ) {
314 const commentBlock = newLines + statement.leadingComments.map( ({ separator, comment }) => {
315 return separator + ( comment.block ?
316 `/*${comment.text}*/` :
317 `//${comment.text}` );
318 }).join( '' );
319
320 magicString.addSource( new MagicString( commentBlock ) );
321 newLines = new Array( statement.margin[0] ).join( '\n' ); // TODO handle gaps between comment block and statement
322 }
323
324 // add the statement itself
325 magicString.addSource({
326 content: source,
327 separator: newLines
328 });
329
330 // add trailing comments
331 const comment = statement.trailingComment;
332 if ( comment ) {
333 const commentBlock = comment.block ?
334 ` /*${comment.text}*/` :
335 ` //${comment.text}`;
336
337 magicString.append( commentBlock );
338 }
339
340 previousMargin = statement.margin[1];
341 previousModule = statement.module;
342 previousIndex = statement.index;
343 });
344
345 // prepend bundle with internal namespaces
346 const indentString = magicString.getIndentString();
347 const namespaceBlock = this.internalNamespaceModules.map( module => {
348 const exportKeys = keys( module.exports );
349
350 return `var ${module.getCanonicalName('*')} = {\n` +
351 exportKeys.map( key => `${indentString}get ${key} () { return ${module.getCanonicalName(key)}; }` ).join( ',\n' ) +
352 `\n};\n\n`;
353 }).join( '' );
354
355 magicString.prepend( namespaceBlock );
356
357 const finalise = finalisers[ format ];
358
359 if ( !finalise ) {
360 throw new Error( `You must specify an output type - valid options are ${keys( finalisers ).join( ', ' )}` );
361 }
362
363 magicString = finalise( this, magicString.trim(), {
364 // Determine export mode - 'default', 'named', 'none'
365 exportMode: getExportMode( this, options.exports ),
366
367 // Determine indentation
368 indentString: getIndentString( magicString, options )
369 }, options );
370
371 const code = magicString.toString();
372 let map = null;
373
374 if ( options.sourceMap ) {
375 map = magicString.generateMap({
376 includeContent: true,
377 file: options.sourceMapFile || options.dest
378 // TODO
379 });
380
381 // make sources relative. TODO fix this upstream?
382 const dir = dirname( map.file );
383 map.sources = map.sources.map( source => {
384 return source ? unixizePath( relative( dir, source ) ) : null
385 });
386 }
387
388 return { code, map };
389 }
390}