file_loader.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. 'use strict';
  2. const assert = require('assert');
  3. const fs = require('fs');
  4. const debug = require('debug')('egg-core:loader');
  5. const path = require('path');
  6. const globby = require('globby');
  7. const is = require('is-type-of');
  8. const deprecate = require('depd')('egg');
  9. const utils = require('../utils');
  10. const FULLPATH = Symbol('EGG_LOADER_ITEM_FULLPATH');
  11. const EXPORTS = Symbol('EGG_LOADER_ITEM_EXPORTS');
  12. const defaults = {
  13. directory: null,
  14. target: null,
  15. match: undefined,
  16. ignore: undefined,
  17. lowercaseFirst: false,
  18. caseStyle: 'camel',
  19. initializer: null,
  20. call: true,
  21. override: false,
  22. inject: undefined,
  23. filter: null,
  24. };
  25. /**
  26. * Load files from directory to target object.
  27. * @since 1.0.0
  28. */
  29. class FileLoader {
  30. /**
  31. * @class
  32. * @param {Object} options - options
  33. * @param {String|Array} options.directory - directories to be loaded
  34. * @param {Object} options.target - attach the target object from loaded files
  35. * @param {String} options.match - match the files when load, support glob, default to all js files
  36. * @param {String} options.ignore - ignore the files when load, support glob
  37. * @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`
  38. * @param {Boolean} options.call - determine whether invoke when exports is function
  39. * @param {Boolean} options.override - determine whether override the property when get the same name
  40. * @param {Object} options.inject - an object that be the argument when invoke the function
  41. * @param {Function} options.filter - a function that filter the exports which can be loaded
  42. * @param {String|Function} options.caseStyle - set property's case when converting a filepath to property list.
  43. */
  44. constructor(options) {
  45. assert(options.directory, 'options.directory is required');
  46. assert(options.target, 'options.target is required');
  47. this.options = Object.assign({}, defaults, options);
  48. // compatible old options _lowercaseFirst_
  49. if (this.options.lowercaseFirst === true) {
  50. deprecate('lowercaseFirst is deprecated, use caseStyle instead');
  51. this.options.caseStyle = 'lower';
  52. }
  53. }
  54. /**
  55. * attach items to target object. Mapping the directory to properties.
  56. * `app/controller/group/repository.js` => `target.group.repository`
  57. * @return {Object} target
  58. * @since 1.0.0
  59. */
  60. load() {
  61. const items = this.parse();
  62. const target = this.options.target;
  63. for (const item of items) {
  64. debug('loading item %j', item);
  65. // item { properties: [ 'a', 'b', 'c'], exports }
  66. // => target.a.b.c = exports
  67. item.properties.reduce((target, property, index) => {
  68. let obj;
  69. const properties = item.properties.slice(0, index + 1).join('.');
  70. if (index === item.properties.length - 1) {
  71. if (property in target) {
  72. if (!this.options.override) throw new Error(`can't overwrite property '${properties}' from ${target[property][FULLPATH]} by ${item.fullpath}`);
  73. }
  74. obj = item.exports;
  75. if (obj && !is.primitive(obj)) {
  76. obj[FULLPATH] = item.fullpath;
  77. obj[EXPORTS] = true;
  78. }
  79. } else {
  80. obj = target[property] || {};
  81. }
  82. target[property] = obj;
  83. debug('loaded %s', properties);
  84. return obj;
  85. }, target);
  86. }
  87. return target;
  88. }
  89. /**
  90. * Parse files from given directories, then return an items list, each item contains properties and exports.
  91. *
  92. * For example, parse `app/controller/group/repository.js`
  93. *
  94. * ```
  95. * module.exports = app => {
  96. * return class RepositoryController extends app.Controller {};
  97. * }
  98. * ```
  99. *
  100. * It returns a item
  101. *
  102. * ```
  103. * {
  104. * properties: [ 'group', 'repository' ],
  105. * exports: app => { ... },
  106. * }
  107. * ```
  108. *
  109. * `Properties` is an array that contains the directory of a filepath.
  110. *
  111. * `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.
  112. * @return {Array} items
  113. * @since 1.0.0
  114. */
  115. parse() {
  116. let files = this.options.match;
  117. if (!files) {
  118. files = (process.env.EGG_TYPESCRIPT === 'true' && utils.extensions['.ts'])
  119. ? [ '**/*.(js|ts)', '!**/*.d.ts' ]
  120. : [ '**/*.js' ];
  121. } else {
  122. files = Array.isArray(files) ? files : [ files ];
  123. }
  124. let ignore = this.options.ignore;
  125. if (ignore) {
  126. ignore = Array.isArray(ignore) ? ignore : [ ignore ];
  127. ignore = ignore.filter(f => !!f).map(f => '!' + f);
  128. files = files.concat(ignore);
  129. }
  130. let directories = this.options.directory;
  131. if (!Array.isArray(directories)) {
  132. directories = [ directories ];
  133. }
  134. const filter = is.function(this.options.filter) ? this.options.filter : null;
  135. const items = [];
  136. debug('parsing %j', directories);
  137. for (const directory of directories) {
  138. const filepaths = globby.sync(files, { cwd: directory });
  139. for (const filepath of filepaths) {
  140. const fullpath = path.join(directory, filepath);
  141. if (!fs.statSync(fullpath).isFile()) continue;
  142. // get properties
  143. // app/service/foo/bar.js => [ 'foo', 'bar' ]
  144. const properties = getProperties(filepath, this.options);
  145. // app/service/foo/bar.js => service.foo.bar
  146. const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
  147. // get exports from the file
  148. const exports = getExports(fullpath, this.options, pathName);
  149. // ignore exports when it's null or false returned by filter function
  150. if (exports == null || (filter && filter(exports) === false)) continue;
  151. // set properties of class
  152. if (is.class(exports)) {
  153. exports.prototype.pathName = pathName;
  154. exports.prototype.fullPath = fullpath;
  155. }
  156. items.push({ fullpath, properties, exports });
  157. debug('parse %s, properties %j, export %j', fullpath, properties, exports);
  158. }
  159. }
  160. return items;
  161. }
  162. }
  163. module.exports = FileLoader;
  164. module.exports.EXPORTS = EXPORTS;
  165. module.exports.FULLPATH = FULLPATH;
  166. // convert file path to an array of properties
  167. // a/b/c.js => ['a', 'b', 'c']
  168. function getProperties(filepath, { caseStyle }) {
  169. // if caseStyle is function, return the result of function
  170. if (is.function(caseStyle)) {
  171. const result = caseStyle(filepath);
  172. assert(is.array(result), `caseStyle expect an array, but got ${result}`);
  173. return result;
  174. }
  175. // use default camelize
  176. return defaultCamelize(filepath, caseStyle);
  177. }
  178. // Get exports from filepath
  179. // If exports is null/undefined, it will be ignored
  180. function getExports(fullpath, { initializer, call, inject }, pathName) {
  181. let exports = utils.loadFile(fullpath);
  182. // process exports as you like
  183. if (initializer) {
  184. exports = initializer(exports, { path: fullpath, pathName });
  185. }
  186. // return exports when it's a class or generator
  187. //
  188. // module.exports = class Service {};
  189. // or
  190. // module.exports = function*() {}
  191. if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
  192. return exports;
  193. }
  194. // return exports after call when it's a function
  195. //
  196. // module.exports = function(app) {
  197. // return {};
  198. // }
  199. if (call && is.function(exports)) {
  200. exports = exports(inject);
  201. if (exports != null) {
  202. return exports;
  203. }
  204. }
  205. // return exports what is
  206. return exports;
  207. }
  208. function defaultCamelize(filepath, caseStyle) {
  209. const properties = filepath.substring(0, filepath.lastIndexOf('.')).split('/');
  210. return properties.map(property => {
  211. if (!/^[a-z][a-z0-9_-]*$/i.test(property)) {
  212. throw new Error(`${property} is not match 'a-z0-9_-' in ${filepath}`);
  213. }
  214. // use default camelize, will capitalize the first letter
  215. // foo_bar.js > FooBar
  216. // fooBar.js > FooBar
  217. // FooBar.js > FooBar
  218. // FooBar.js > FooBar
  219. // FooBar.js > fooBar (if lowercaseFirst is true)
  220. property = property.replace(/[_-][a-z]/ig, s => s.substring(1).toUpperCase());
  221. let first = property[0];
  222. switch (caseStyle) {
  223. case 'lower':
  224. first = first.toLowerCase();
  225. break;
  226. case 'upper':
  227. first = first.toUpperCase();
  228. break;
  229. case 'camel':
  230. default:
  231. }
  232. return first + property.substring(1);
  233. });
  234. }