d3-jsnext
Version:
d3, but futuristic
303 lines (239 loc) • 8.22 kB
JavaScript
import { dirname, relative, resolve } from 'path';
import { readFileSync } from 'sander';
import { parse } from 'acorn';
import MagicString from 'magic-string';
import attachScopes from './ast/attachScopes';
import walk from './ast/walk';
import createAlias from './utils/createAlias';
import dedupe from './utils/dedupe';
import isExport from './utils/isExport';
function isIdentifier ( node, parent ) {
if ( node.type !== 'Identifier' ) return false;
if ( parent.type === 'MemberExpression' && !parent.computed && node !== parent.object ) return false;
if ( parent.type === 'Property' && node !== parent.value ) return false;
return true;
}
function getKeypath ( node ) {
if ( node.type !== 'MemberExpression' || node.computed ) return null;
let keypath = node.property.name;
node = node.object;
while ( node.type === 'MemberExpression' ) {
if ( node.computed ) return null;
keypath = `${node.property.name}.${keypath}`;
node = node.object;
}
if ( node.type !== 'Identifier' ) return null;
return `${node.name}.${keypath}`;
}
function readSource ( file, included ) {
const dir = dirname( file );
let lastIndex = 0;
return readFileSync( file ).toString()
.replace( /^\/\/\s*import.+?\n/gm, '' ) // ...(including commented-out ones...)
.replace( /^import \"(.+?)\";?\n/gm, ( match, relativePath, index ) => {
// ...except some. In most cases, we want to strip out import statements
// altogether and reconstruct them from the ASTs we're about to generate.
// There are a couple of places, however, where that won't work, because
// imports exist for their side-effects rather than their dependencies.
// (This is an undesirable pattern with ES6 modules, but that's a subject
// for another time.) As an easy way of including them, we identify
// side-effecty imports (because they're not at the top of the module -
// they come later on, after e.g. `d3_selection.prototype` has been
// defined) and basically copy-and-paste them in. This is, it goes
// without saying, a dreadful hack.
if ( index > lastIndex ) {
const path = resolve( dir, relativePath + '.js' );
included[ path ] = true;
const include = readSource( path, included );
return include + '\n\n';
}
lastIndex = index + match.length;
return '';
})
.trim();
}
export default class Module {
constructor ( file ) {
this.file = file;
this.dir = dirname( file );
let lastIndex = 0;
this.src = readSource( file, this.included = {} );
this.magicString = new MagicString( this.src );
// Attempt to parse with acorn
try {
this.ast = parse( this.src );
} catch ( err ) {
console.log( this.src );
console.log( `error parsing ${file}: ${err.message}` );
throw err;
}
this.internalDependencies = {};
this.exportDependencies = {};
this.internalNameByExportName = {};
this.exports = {};
this.analyse();
this.definitions = this.ast._scope.names.slice();
}
analyse () {
let scope = attachScopes( this.ast );
walk( this.ast, {
enter: ( node, parent ) => {
if ( node._scope ) {
scope = node._scope;
}
if ( isIdentifier( node, parent ) && !scope.contains( node.name ) ) {
this.internalDependencies[ node.name ] = true;
}
const keypath = getKeypath( node );
if ( isExport( keypath ) ) {
this.exportDependencies[ keypath ] = true;
}
// check for assignments to d3.whatever
if ( node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression' ) {
const keypath = getKeypath( node.left );
if ( isExport( keypath ) ) {
this.exports[ keypath ] = true;
if ( node.right.type === 'Identifier' ) {
this.internalNameByExportName[ keypath ] = node.right.name;
node._shouldRemove = true;
return;
}
}
}
},
leave: ( node, parent ) => {
if ( node._scope ) {
scope = scope.parent;
}
}
});
}
render ({
pathByExportName,
exportNamesByPath,
pathByInternalName,
internalNamesByPath,
internalNameByExportName,
exportNameByInternalName
}) {
const self = this;
const magicString = this.magicString;
let scope = this.ast._scope;
let varsToDeclare = {};
walk( this.ast, {
enter ( node, parent ) {
if ( node._scope ) {
scope = node._scope;
}
// rewrite top-level `this` as `window`
if ( node.type === 'ThisExpression' && !scope.parent ) {
magicString.overwrite( node.start, node.end, 'window' );
}
// remove e.g. `d3.ascending = d3_ascending`
// TODO we don't always want this...
if ( node._shouldRemove ) {
//console.log( 'should remove', node );
// let end = node.end;
// const remaining = magicString.slice( end );
// const match = /^;\n*/.exec( remaining );
//
// if ( match ) end += match[0].length;
//
// magicString.remove( node.start, end );
// return this.skip();
}
// if we encounter `d3.whatever = ...` it gets turned into
// `d3$whatever = ...`, so we need to declare `var d3$whatever`
if ( node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression' ) {
const keypath = getKeypath( node.left );
if ( isExport( keypath ) ) {
const alias = internalNameByExportName[ keypath ] || createAlias( keypath );
if ( alias ) {
varsToDeclare[ alias ] = true;
}
}
}
// rewrite all other instances of `d3.whatever` to `d3_whatever`, but be
// careful with e.g. `d3.ns.qualify` -> `d3$ns.qualify`
if ( node.type === 'MemberExpression' ) {
const keypath = getKeypath( node );
if ( keypath === 'd3.event' ) {
// TODO fix this, once things actually work
magicString.overwrite( node.start, node.end, 'window.d3_event' );
}
if ( !!pathByExportName[ keypath ] ) {
const alias = keypath.replace( /\./g, '$' );
magicString.overwrite( node.start, node.end, alias );
return this.skip();
}
}
},
leave ( node ) {
if ( node._scope ) {
scope = scope.parent;
}
}
});
let dependencies = {};
let imported = {};
const addDependency = ( owner, name ) => {
if ( !owner || owner === this.file ) return;
if ( imported[ name ] ) {
if ( owner !== imported[ name ] ) {
throw new Error( `Importing ${name} from separate locations: ${imported[ name ]}, ${owner}` );
}
return;
}
imported[ name ] = owner;
let relativePath = relative( this.dir, owner ).replace( /\.js$/, '' );
if ( relativePath[0] !== '.' ) relativePath = `./${relativePath}`;
if ( !dependencies[ relativePath ] ) {
dependencies[ relativePath ] = [];
}
dependencies[ relativePath ].push( name );
};
Object.keys( this.internalDependencies ).forEach( name => {
addDependency( pathByInternalName[ name ], name );
});
Object.keys( this.exportDependencies ).forEach( keypath => {
const keys = keypath.split( '.' );
let owner;
while ( keys.length ) {
keypath = keys.join( '.' );
owner = pathByExportName[ keypath ];
if ( owner ) break;
keys.pop();
}
if ( !owner ) return;
const alias = keypath.replace( /\./g, '$' );
addDependency( owner, alias );
});
const varDeclarationBlock = Object.keys( varsToDeclare )
.map( name => `var ${name};` )
.join( '\n' );
if ( varDeclarationBlock ) {
magicString.prepend( varDeclarationBlock + '\n\n' );
}
const importBlock = Object.keys( dependencies ).map( relativePath => {
const names = dependencies[ relativePath ];
return `import { ${names.join( ', ' )} } from '${relativePath}';`
}).join( '\n' );
if ( importBlock ) {
magicString.prepend( importBlock + '\n\n' );
}
let shouldExport = dedupe(
exportNamesByPath[ this.file ]
.map( name => {
const alias = internalNameByExportName[ name ] || createAlias( name );
return alias;
})
.concat( internalNamesByPath[ this.file ])
);
const exportBlock = shouldExport.length > 4 ?
`\n\nexport {\n ${shouldExport.join(',\n ')}\n};` :
shouldExport.length > 0 ?
`\n\nexport { ${shouldExport.join(', ')} };` :
'';
return magicString.append( exportBlock ).toString().trim();
}
}