'use strict';
|
|
const assert = require('assert');
|
const fs = require('fs');
|
const debug = require('debug')('ee-core:fileLoader');
|
const path = require('path');
|
const globby = require('globby');
|
const is = require('is-type-of');
|
const deprecate = require('../../../utils/depd')('ee');
|
const Utils = require('../utils');
|
const FULLPATH = Symbol('EE_LOADER_ITEM_FULLPATH');
|
const EXPORTS = Symbol('EE_LOADER_ITEM_EXPORTS');
|
//const Addon = require('../../../addon');
|
|
const defaults = {
|
directory: null,
|
target: null,
|
match: undefined,
|
ignore: undefined,
|
lowercaseFirst: false,
|
caseStyle: 'camel',
|
initializer: null,
|
call: true,
|
override: false,
|
inject: undefined,
|
filter: null,
|
loader: undefined,
|
};
|
|
/**
|
* 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.
|
* @param {Object} options.loader - an object that be the argument when invoke the function
|
*/
|
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) {
|
// 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.EE_TYPESCRIPT === 'true' && Utils.extensions['.ts'])
|
? [ '**/*.(js|ts)', '!**/*.d.ts' ]
|
: [ '**/*.js', '**/*.jsc'];
|
} 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
|
let 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) || Utils.isBytecodeClass(exports)) {
|
exports.prototype.pathName = pathName;
|
exports.prototype.fullPath = fullpath;
|
}
|
|
items.push({ fullpath, properties, exports });
|
debug('parse %s, properties %j, export %O', fullpath, properties, exports);
|
}
|
}
|
|
return items;
|
}
|
|
/**
|
* attach items to target object for addons.
|
* `app/addon/group/index.js` => `target.group`
|
* @return {Object} target
|
* @since 1.0.0
|
*/
|
loadAddons() {
|
const items = [];
|
const files = '*';
|
const app = this.options.inject;
|
const loader = this.options.loader;
|
const target = this.options.target;
|
let directories = this.options.directory;
|
|
if (!Array.isArray(directories)) {
|
directories = [ directories ];
|
}
|
|
// check addon
|
const config = app.config.addons;
|
let filterAddons = [];
|
for (let key of Object.keys(config)) {
|
if (!config[key].enable) {
|
filterAddons.push(key);
|
}
|
}
|
|
for (const directory of directories) {
|
const addonpaths = globby.sync(files, { cwd: directory, deep: 1, onlyDirectories: true});
|
for (const addonName of addonpaths) {
|
if (filterAddons.includes(addonName)) continue;
|
|
let fullpath = path.join(directory, addonName, 'index');
|
fullpath = loader.resolveModule(fullpath);
|
if (!fs.statSync(fullpath).isFile()) continue;
|
|
let exports = getExports(fullpath, this.options, addonName);
|
if (exports == null) continue;
|
|
const properties = [addonName];
|
if (is.class(exports) || Utils.isBytecodeClass(exports)) {
|
exports.prototype.pathName = addonName;
|
exports.prototype.fullPath = fullpath;
|
}
|
|
items.push({ fullpath, properties, exports });
|
}
|
|
for (const item of items) {
|
const property = item.properties[0];
|
let obj = item.exports;
|
if (obj && !is.primitive(obj)) {
|
obj[FULLPATH] = item.fullpath;
|
obj[EXPORTS] = true;
|
}
|
|
target[property] = obj;
|
}
|
}
|
|
return target;
|
}
|
}
|
|
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*() {}
|
//new exports;
|
|
if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports) || Utils.isBytecodeClass(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);
|
});
|
}
|