'use strict'; const assert = require('assert'); const fs = require('fs'); const debug = require('debug')('egg-core:loader'); const path = require('path'); const globby = require('globby'); const is = require('is-type-of'); const deprecate = require('depd')('egg'); const utils = require('../utils'); const FULLPATH = Symbol('EGG_LOADER_ITEM_FULLPATH'); const EXPORTS = Symbol('EGG_LOADER_ITEM_EXPORTS'); const defaults = { directory: null, target: null, match: undefined, ignore: undefined, lowercaseFirst: false, caseStyle: 'camel', initializer: null, call: true, override: false, inject: undefined, filter: null, }; /** * Load files from directory to target object. * @since 1.0.0 */ class FileLoader { /** * @class * @param {Object} options - options * @param {String|Array} options.directory - directories to be loaded * @param {Object} options.target - attach the target object from loaded files * @param {String} options.match - match the files when load, support glob, default to all js files * @param {String} options.ignore - ignore the files when load, support glob * @param {Function} options.initializer - custom file exports, receive two parameters, first is the inject object(if not js file, will be content buffer), second is an `options` object that contain `path` * @param {Boolean} options.call - determine whether invoke when exports is function * @param {Boolean} options.override - determine whether override the property when get the same name * @param {Object} options.inject - an object that be the argument when invoke the function * @param {Function} options.filter - a function that filter the exports which can be loaded * @param {String|Function} options.caseStyle - set property's case when converting a filepath to property list. */ constructor(options) { assert(options.directory, 'options.directory is required'); assert(options.target, 'options.target is required'); this.options = Object.assign({}, defaults, options); // compatible old options _lowercaseFirst_ if (this.options.lowercaseFirst === true) { deprecate('lowercaseFirst is deprecated, use caseStyle instead'); this.options.caseStyle = 'lower'; } } /** * attach items to target object. Mapping the directory to properties. * `app/controller/group/repository.js` => `target.group.repository` * @return {Object} target * @since 1.0.0 */ load() { const items = this.parse(); const target = this.options.target; for (const item of items) { debug('loading item %j', item); // item { properties: [ 'a', 'b', 'c'], exports } // => target.a.b.c = exports item.properties.reduce((target, property, index) => { let obj; const properties = item.properties.slice(0, index + 1).join('.'); if (index === item.properties.length - 1) { if (property in target) { if (!this.options.override) throw new Error(`can't overwrite property '${properties}' from ${target[property][FULLPATH]} by ${item.fullpath}`); } obj = item.exports; if (obj && !is.primitive(obj)) { obj[FULLPATH] = item.fullpath; obj[EXPORTS] = true; } } else { obj = target[property] || {}; } target[property] = obj; debug('loaded %s', properties); return obj; }, target); } return target; } /** * Parse files from given directories, then return an items list, each item contains properties and exports. * * For example, parse `app/controller/group/repository.js` * * ``` * module.exports = app => { * return class RepositoryController extends app.Controller {}; * } * ``` * * It returns a item * * ``` * { * properties: [ 'group', 'repository' ], * exports: app => { ... }, * } * ``` * * `Properties` is an array that contains the directory of a filepath. * * `Exports` depends on type, if exports is a function, it will be called. if initializer is specified, it will be called with exports for customizing. * @return {Array} items * @since 1.0.0 */ parse() { let files = this.options.match; if (!files) { files = (process.env.EGG_TYPESCRIPT === 'true' && utils.extensions['.ts']) ? [ '**/*.(js|ts)', '!**/*.d.ts' ] : [ '**/*.js' ]; } else { files = Array.isArray(files) ? files : [ files ]; } let ignore = this.options.ignore; if (ignore) { ignore = Array.isArray(ignore) ? ignore : [ ignore ]; ignore = ignore.filter(f => !!f).map(f => '!' + f); files = files.concat(ignore); } let directories = this.options.directory; if (!Array.isArray(directories)) { directories = [ directories ]; } const filter = is.function(this.options.filter) ? this.options.filter : null; const items = []; debug('parsing %j', directories); for (const directory of directories) { const filepaths = globby.sync(files, { cwd: directory }); for (const filepath of filepaths) { const fullpath = path.join(directory, filepath); if (!fs.statSync(fullpath).isFile()) continue; // get properties // app/service/foo/bar.js => [ 'foo', 'bar' ] const properties = getProperties(filepath, this.options); // app/service/foo/bar.js => service.foo.bar const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.'); // get exports from the file const exports = getExports(fullpath, this.options, pathName); // ignore exports when it's null or false returned by filter function if (exports == null || (filter && filter(exports) === false)) continue; // set properties of class if (is.class(exports)) { exports.prototype.pathName = pathName; exports.prototype.fullPath = fullpath; } items.push({ fullpath, properties, exports }); debug('parse %s, properties %j, export %j', fullpath, properties, exports); } } return items; } } module.exports = FileLoader; module.exports.EXPORTS = EXPORTS; module.exports.FULLPATH = FULLPATH; // convert file path to an array of properties // a/b/c.js => ['a', 'b', 'c'] function getProperties(filepath, { caseStyle }) { // if caseStyle is function, return the result of function if (is.function(caseStyle)) { const result = caseStyle(filepath); assert(is.array(result), `caseStyle expect an array, but got ${result}`); return result; } // use default camelize return defaultCamelize(filepath, caseStyle); } // Get exports from filepath // If exports is null/undefined, it will be ignored function getExports(fullpath, { initializer, call, inject }, pathName) { let exports = utils.loadFile(fullpath); // process exports as you like if (initializer) { exports = initializer(exports, { path: fullpath, pathName }); } // return exports when it's a class or generator // // module.exports = class Service {}; // or // module.exports = function*() {} if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) { return exports; } // return exports after call when it's a function // // module.exports = function(app) { // return {}; // } if (call && is.function(exports)) { exports = exports(inject); if (exports != null) { return exports; } } // return exports what is return exports; } function defaultCamelize(filepath, caseStyle) { const properties = filepath.substring(0, filepath.lastIndexOf('.')).split('/'); return properties.map(property => { if (!/^[a-z][a-z0-9_-]*$/i.test(property)) { throw new Error(`${property} is not match 'a-z0-9_-' in ${filepath}`); } // use default camelize, will capitalize the first letter // foo_bar.js > FooBar // fooBar.js > FooBar // FooBar.js > FooBar // FooBar.js > FooBar // FooBar.js > fooBar (if lowercaseFirst is true) property = property.replace(/[_-][a-z]/ig, s => s.substring(1).toUpperCase()); let first = property[0]; switch (caseStyle) { case 'lower': first = first.toLowerCase(); break; case 'upper': first = first.toUpperCase(); break; case 'camel': default: } return first + property.substring(1); }); }