UNPKG

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