bajo 2.23.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.jsdoc.conf.json +2 -1
  2. package/class/app.js +220 -107
  3. package/class/bajo.js +257 -177
  4. package/class/base.js +85 -24
  5. package/class/cache.js +8 -16
  6. package/class/err.js +31 -10
  7. package/class/log.js +46 -38
  8. package/class/plugin.js +53 -30
  9. package/class/print.js +18 -15
  10. package/class/tools.js +17 -7
  11. package/docs/App.html +51 -1
  12. package/docs/Bajo.html +1 -1
  13. package/docs/Base.html +34 -1
  14. package/docs/Cache.html +1 -1
  15. package/docs/Err.html +2 -2
  16. package/docs/Log.html +2 -2
  17. package/docs/Main.html +3 -0
  18. package/docs/Plugin.html +1 -1
  19. package/docs/Print.html +1 -1
  20. package/docs/Tools.html +1 -1
  21. package/docs/class_app.js.html +222 -109
  22. package/docs/class_bajo.js.html +260 -179
  23. package/docs/class_base.js.html +87 -26
  24. package/docs/class_cache.js.html +10 -18
  25. package/docs/class_err.js.html +33 -12
  26. package/docs/class_log.js.html +48 -40
  27. package/docs/class_plugin.js.html +55 -32
  28. package/docs/class_print.js.html +20 -17
  29. package/docs/class_tools.js.html +19 -9
  30. package/docs/data/search.json +1 -1
  31. package/docs/global.html +22 -1
  32. package/docs/index.html +1 -1
  33. package/docs/index.js.html +53 -16
  34. package/docs/lib_applet-hook.js.html +328 -0
  35. package/docs/lib_config.js.html +167 -0
  36. package/docs/lib_helper.js.html +186 -192
  37. package/docs/module-Applet.html +3 -0
  38. package/docs/module-Helper.html +1 -8
  39. package/docs/module-Hook.html +1 -1
  40. package/docs/tutorial-01_WELCOME.html +3 -0
  41. package/docs/tutorial-02_GETTING_STARTED.html +125 -0
  42. package/docs/tutorial-03_USER_GUIDE.html +54 -0
  43. package/docs/tutorial-04_DEV_GUIDE.html +178 -0
  44. package/docs/tutorial-05_ECOSYSTEM.html +3 -0
  45. package/extend/bajo/intl/en-US.json +2 -1
  46. package/extend/bajo/intl/id.json +2 -1
  47. package/index.js +51 -14
  48. package/lib/applet-hook.js +325 -0
  49. package/lib/config.js +164 -0
  50. package/lib/helper.js +185 -191
  51. package/package.json +6 -2
  52. package/test/00-global-hooks.test.js +44 -0
  53. package/test/e2e/_run.js +19 -0
  54. package/test/e2e/e2e-applet-process.test.js +66 -0
  55. package/test/e2e/e2e-boot-process.test.js +62 -0
  56. package/test/e2e/e2e-hooks-process.test.js +68 -0
  57. package/test/integration/_setup.js +55 -0
  58. package/test/integration/aspect-01-boot-instance.test.js +24 -0
  59. package/test/integration/aspect-02-plugin-loading.test.js +24 -0
  60. package/test/integration/aspect-03-config-merge.test.js +27 -0
  61. package/test/integration/aspect-04-intl-loading.test.js +29 -0
  62. package/test/integration/aspect-05-cache-roundtrip.test.js +26 -0
  63. package/test/integration/aspect-06-log-file-save.test.js +28 -0
  64. package/test/integration/aspect-07-plugin-data-dir.test.js +25 -0
  65. package/test/integration/aspect-08-plugin-lookup.test.js +25 -0
  66. package/test/integration/aspect-09-config-formats.test.js +26 -0
  67. package/test/integration/aspect-10-hooks.test.js +38 -0
  68. package/test/unit/app.test.js +115 -0
  69. package/test/unit/applet-hook.test.js +10 -0
  70. package/test/unit/bajo.test.js +151 -0
  71. package/test/unit/base.test.js +90 -0
  72. package/test/unit/cache.test.js +89 -0
  73. package/test/unit/config.test.js +14 -0
  74. package/test/unit/err.test.js +57 -0
  75. package/test/unit/helper.test.js +116 -0
  76. package/test/unit/index.test.js +40 -0
  77. package/test/unit/log.test.js +84 -0
  78. package/test/unit/plugin.test.js +95 -0
  79. package/test/unit/print.test.js +72 -0
  80. package/test/unit/tools.test.js +29 -0
  81. package/wiki/CHANGES.md +10 -0
  82. package/wiki/tutorials/01_WELCOME.md +18 -0
  83. package/wiki/tutorials/02_GETTING_STARTED.md +366 -0
  84. package/wiki/tutorials/03_USER_GUIDE.md +262 -0
  85. package/wiki/tutorials/04_DEV_GUIDE.md +386 -0
  86. package/wiki/tutorials/05_ECOSYSTEM.md +95 -0
  87. package/wiki/tutorials/tutorials.json +17 -0
  88. package/docs/lib_hook.js.html +0 -231
  89. package/docs/module-Lib.html +0 -3
  90. package/lib/hook.js +0 -228
  91. package/test/app.test.js +0 -183
  92. package/test/bajo.test.js +0 -124
  93. package/test/base.test.js +0 -75
  94. package/test/cache.test.js +0 -94
  95. package/test/e2e.test.js +0 -137
  96. package/test/err.test.js +0 -73
  97. package/test/helper.test.js +0 -39
  98. package/test/import-module.test.js +0 -138
  99. package/test/integration.test.js +0 -218
  100. package/test/log.test.js +0 -119
  101. package/test/plugin.test.js +0 -116
  102. package/test/print.test.js +0 -100
  103. package/test/tools.test.js +0 -38
  104. package/wiki/CONFIG.md +0 -36
  105. package/wiki/DEV_GUIDE.md +0 -3
  106. package/wiki/ECOSYSTEM.md +0 -93
  107. package/wiki/GETTING-STARTED.md +0 -356
  108. package/wiki/USER-GUIDE.md +0 -256
package/class/bajo.js CHANGED
@@ -13,11 +13,14 @@ import * as yaml from 'js-yaml'
13
13
  import aneka from 'aneka'
14
14
  import {
15
15
  buildBaseConfig,
16
- buildExtConfig,
16
+ buildConfig,
17
17
  buildPlugins,
18
18
  collectConfigHandlers,
19
19
  bootOrder,
20
- bootPlugins,
20
+ checkNameAliases,
21
+ checkDependencies,
22
+ collectHooks,
23
+ runPlugins,
21
24
  exitHandler,
22
25
  importModule,
23
26
  types as formatTypes,
@@ -27,20 +30,20 @@ import {
27
30
  const require = createRequire(import.meta.url)
28
31
 
29
32
  const {
30
- isFunction, map, isObject,
33
+ isFunction, map, isObject, findIndex, uniq, merge, mergeWith,
31
34
  trim, filter, isEmpty, orderBy, pullAt, find, camelCase,
32
35
  cloneDeep, isPlainObject, isArray, isString, omit, keys, indexOf,
33
- last, get, has, values, pick
36
+ last, get, has, values, pick, isBoolean
34
37
  } = lodash
35
38
 
36
- const { resolvePath, getGlobalModuleDir } = aneka
39
+ const { resolvePath, getGlobalModuleDir, defaultsDeep, isSet, parseObject } = aneka
37
40
 
38
41
  /**
39
- * Name based `{ns}:{path}` format.
42
+ * Name in `{ns}:{path}` format.
40
43
  *
41
44
  * @typedef {string} TNsPathPairs
42
45
  * @memberof Bajo
43
- * @see TNsPathResult
46
+ * @see Bajo.TNsPathResult
44
47
  * @see Bajo#buildNsPath
45
48
  * @see Bajo#breakNsPath
46
49
  */
@@ -56,23 +59,30 @@ const { resolvePath, getGlobalModuleDir } = aneka
56
59
  */
57
60
 
58
61
  /**
59
- * Object returned by {@link Bajo#breakNsPath|bajo:breakNsPath}.
62
+ * Object returned by {@link Bajo#breakNsPath|bajo:breakNsPath()}.
60
63
  *
61
64
  * @typedef {Object} TNsPathResult
62
65
  * @memberof Bajo
63
- * @property {string} ns - Namespace
66
+ * @property {string} [ns] - Namespace
64
67
  * @property {string} [subNs] - Sub namespace
65
68
  * @property {string} [subSubNs] - Sub of sub namespace
69
+ * @property {string} [fullNs] - Full namespace, including sub namespaces
66
70
  * @property {string} path - Path without query string or hash
67
- * @property {string} fullPath - Full path, including query string and hash
68
- * @see TNsPathPairs
71
+ * @property {string} [fullPath] - Full path, including query string and hash
72
+ * @property {string} [realPath] - Path without query string or hash, but with parameters replaced with their values
73
+ * @property {string} [realFullPath] - Full path, including query string and hash, but with parameters replaced with their values
74
+ * @property {Object} [qs] - Query string object
75
+ * @property {Object} [params] - Parameters object
76
+ * @see Bajo.TNsPathPairs
69
77
  * @see Bajo#buildNsPath
70
78
  * @see Bajo#breakNsPath
71
79
  */
72
80
 
73
81
  /**
74
- * The Core. The main engine. The one and only plugin that control app's boot process and
75
- * making sure all other plugins working nicely.
82
+ * The core plugin. The main engine. The special plugin that controls the app's boot process and
83
+ * makes sure all other plugins work nicely.
84
+ *
85
+ * Don't create your own instance of this class or {@link App} class. Instead, use the {@link boot} process.
76
86
  *
77
87
  * @class
78
88
  */
@@ -86,57 +96,61 @@ class Bajo extends Plugin {
86
96
  super('bajo', app)
87
97
 
88
98
  /**
89
- * Alias
99
+ * Alias. Read-only
90
100
  * @type {string}
101
+ * @default 'bajo'
91
102
  */
92
103
  this.alias = 'bajo'
93
104
 
94
105
  /**
95
- * White space characters
106
+ * Array of white space characters. Used to trim strings
96
107
  * @type {string[]}
97
108
  */
98
109
  this.whiteSpace = [' ', '\t', '\n', '\r']
99
110
 
100
111
  /**
101
- * Config object.
102
- *
103
- * @type {Bajo.TObject}
112
+ * Configuration object
113
+ * @type {Bajo.TConfig}
104
114
  * @see {@tutorial config}
105
115
  */
106
116
  this.config = {}
107
117
 
108
118
  /**
109
- * Hooks container.
110
- *
119
+ * Hooks container. This is where all hooks definition are stored.
111
120
  * @type {Array<Bajo.THook>}
112
121
  */
113
122
  this.hooks = []
114
123
  }
115
124
 
116
125
  /**
117
- * Initialization:
118
- *
119
- * 1. Building {@link module:Helper/Bajo.buildBaseConfig|base config}
120
- * 2. {@link module:Helper/Bajo.buildPlugins|Building plugins}
121
- * 3. Collect all {@link module:Helper/Bajo.collectConfigHandlers|config handler}
122
- * 4. Building {@link module:Helper/Bajo.buildExtConfig|extra config}
123
- * 5. Setup {@link module:Helper/Bajo.bootOrder|boot order}
124
- * 6. {@link module:Helper/Bajo.bootPlugins|Boot loaded plugins}
125
- * 7. Attach {@link module:Helper/Bajo.exitHandler|exit handlers}
126
+ * Bajo boot process. This method is called by the {@link App} class during the app's boot process:
127
+ * 1. Building Bajo's {@link module:Helper.buildBaseConfig|base configuration}
128
+ * 2. Collect all {@link module:Helper.collectConfigHandlers|config handlers} from all loaded plugins
129
+ * 3. Build {@link module:Helper.buildConfig|configuration} object
130
+ * 4. {@link module:Helper.buildPlugins|Building plugins} listed in `package.json` or `.plugins` file
131
+ * 5. Determining the {@link module:Helper.bootOrder|boot order}
132
+ * 6. Ensure the {@link module:Helper.checkNameAliases|uniqueness} of all plugins' name and alias
133
+ * 7. Ensure all plugins {@link module:Helper.checkDependencies|dependencies} are met
134
+ * 8. Collect all {@link module:Helper.collectHooks|hooks} from all loaded plugins
135
+ * 9. {@link module:Helper.runPlugins|Run all plugins} according to the boot order
136
+ * 10. And finally attaching all {@link module:Helper.exitHandler|exit handlers} it could find
126
137
  *
127
138
  * @method
128
139
  * @async
129
140
  */
130
- init = async () => {
141
+ bootApp = async () => {
131
142
  await buildBaseConfig.call(this)
132
143
  await collectConfigHandlers.call(this)
133
- await buildExtConfig.call(this)
144
+ await buildConfig.call(this)
134
145
  await buildPlugins.call(this)
135
146
  await bootOrder.call(this)
136
- await bootPlugins.call(this)
147
+ await checkNameAliases.call(this)
148
+ await checkDependencies.call(this)
149
+ await collectHooks.call(this)
150
+ await runPlugins.call(this)
137
151
  await exitHandler.call(this)
138
152
  if (this.app.bajoSpatial) {
139
- this.anekaSpatial = await this.importPkg('bajoSpatial:aneka-spatial')
153
+ this.app.lib.anekaSpatial = await this.importPkg('bajoSpatial:aneka-spatial')
140
154
  }
141
155
  }
142
156
 
@@ -191,12 +205,24 @@ class Bajo extends Plugin {
191
205
  }
192
206
 
193
207
  /**
194
- * Break name to its namespace & path infos
208
+ * Break `name` to their components.
209
+ *
210
+ * If path starts with `//`, e.g. name is `https://example.com`, it will be treated as a URL and returned as an
211
+ * object with only `path` property filled with the original name.
212
+ *
213
+ * If query string is present in the path, it will be parsed and returned as an object with `qs`
214
+ * property containing the parsed query string.
215
+ *
216
+ * If path contains parameters in the format of `:key|value` or `{key|value}`, they will be extracted and returned
217
+ * as an object with `params` property containing the extracted parameters.
218
+ *
219
+ * This method is one of the most used method in Bajo. It is because every files, names, resources, and even commands
220
+ * are all identified by their namespace and path.
195
221
  *
196
222
  * @method
197
- * @param {(TNsPathPairs|string)} name - Name to break
198
- * @param {boolean} [checkNs=true] - If `true` (default), namespace will be checked for its validity
199
- * @returns {TNsPathResult}
223
+ * @param {Bajo.TNsPathPairs} name - Name in format `ns:path`
224
+ * @param {boolean} [checkNs=true] - If `true` (default), check if the namespace exists in the app. If not, throw an error
225
+ * @returns {Bajo.TNsPathResult}
200
226
  */
201
227
  breakNsPath = (name = '', checkNs = true) => {
202
228
  let [ns, ...path] = name.split(':')
@@ -225,14 +251,17 @@ class Bajo extends Plugin {
225
251
  const params = {}
226
252
  for (const idx in parts) {
227
253
  const part = parts[idx]
228
- if (part[0] !== ':' || part.indexOf('|') === -1) {
254
+ if (part.indexOf('|') === -1 && ![':', '{'].includes(part[0])) {
229
255
  realParts.push(part)
230
256
  continue
231
257
  }
232
- const [key, val] = part.split('|')
233
- parts[idx] = key
234
- params[key.slice(1)] = val
235
- realParts.push(val)
258
+ let [key, val] = part.split('|')
259
+ if (!val) val = key[0] === '{' ? '}' : ''
260
+ key = key.slice(1)
261
+ val = val[val.length - 1] === '}' ? val.slice(0, val.length - 1) : val
262
+ // parts[idx] = key
263
+ params[key] = val
264
+ realParts.push(isEmpty(val) ? parts[idx] : val)
236
265
  }
237
266
  path = parts.join('/')
238
267
  const realPath = realParts.join('/')
@@ -240,33 +269,39 @@ class Bajo extends Plugin {
240
269
  if (!isEmpty(qs)) fullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) }))
241
270
  let realFullPath = realPath
242
271
  if (!isEmpty(qs)) realFullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) }))
243
- return { ns, path, subNs, subSubNs, qs, fullPath, fullNs, realPath, realFullPath }
272
+ return { ns, path, subNs, subSubNs, qs, params, fullPath, fullNs, realPath, realFullPath }
244
273
  }
245
274
 
246
275
  /**
247
- * Method to transform config's array or object into an array of collection.
276
+ * Method to transform config's array or object into a collection of objects with uniformed structure. This is useful to build a collection of items
277
+ * from config's array or object, e.g. a collection of commands, routes, etc.
278
+ *
279
+ * You typically also provide a `handler` function to transform each item in the collection. The handler function will be called with an object
280
+ * containing the following keys:
281
+ * - `item`: the current item in the collection
282
+ * - `index`: the index of the current item in the collection
283
+ * - `cfg`: the entire config object
248
284
  *
249
285
  * @method
250
286
  * @async
251
- * @param {Object} options - Options.
252
- * @param {string} [options.ns] - Namespace. If not provided, defaults to `bajo`
253
- * @param {function} [options.handler] - Handler to call while building the collection item.
254
- * @param {string[]} [options.dupChecks=[]] - Array of keys to check for duplicates.
255
- * @param {string} options.container - Key used as container name.
256
- * @param {boolean} [options.useDefaultName=true] - If true (default) and `name` key is not provided, returned collection will be named `default`.
257
- * @fires bajo:beforeBuildCollection
258
- * @fires bajo:afterBuildCollection
259
- * @returns {Object[]} The collection
287
+ * @param {Object} [options={}] - Options
288
+ * @param {string} [options.ns='bajo'] - Namespace. If not provided, defaults to `bajo`
289
+ * @param {function} [options.handler] - Function to transform each item while building
290
+ * @param {string[]} [options.dupChecks=[]] - Array of keys to check for duplicates
291
+ * @param {string} options.container - Key used as container name
292
+ * @param {boolean} [options.useDefaultName=true] - If true (default) and `name` key is not provided, returned item will be named `default`
293
+ * @returns {Array<Object>} Returned collection of objects
294
+ * @see module:Hook.{ns}:beforeBuildCollection
295
+ * @see module:Hook.{ns}:afterBuildCollection
260
296
  */
261
297
  buildCollections = async (options = {}) => {
262
298
  const { parseObject } = this.app.lib
263
- let { ns, handler, dupChecks = [], container, useDefaultName = true, noDefault = true } = options
264
- if (!ns) ns = this.ns
299
+ const { ns = this.ns, handler, dupChecks = [], container, useDefaultName = true, noDefault = true } = options
265
300
  const cfg = this.app[ns].getConfig()
266
301
  let items = get(cfg, container, [])
267
302
  if (!isArray(items)) items = [items]
268
303
  this.app[ns].log.trace('collecting%s', this.t(container))
269
- await this.runHook(`${ns}:beforeBuildCollection`, container)
304
+ await this.runHook(`${ns}:beforeBuildCollection`, container, items)
270
305
  const deleted = []
271
306
  for (const index in items) {
272
307
  const item = parseObject(items[index])
@@ -300,8 +335,7 @@ class Bajo extends Plugin {
300
335
  }
301
336
 
302
337
  /**
303
- * Calling any plugin's method by its name:
304
- *
338
+ * Calling a function handler in any plugins:
305
339
  * - If name is a string, the corresponding plugin's method will be called with passed args as its parameters
306
340
  * - If name is a plugin instance, this will be used as the scope instead. The first args is now the handler name and the rest are its parameters
307
341
  * - If name is a function, this function will be run under scope with the remaining args
@@ -309,9 +343,9 @@ class Bajo extends Plugin {
309
343
  *
310
344
  * @method
311
345
  * @async
312
- * @param {(TNsPathPairs|Object|function)} name - Method's name, function handler, plain object or plugin instance.
313
- * @param {...any} [args] - One or more arguments passed as parameter to the handler.
314
- * @returns {any} Returned value.
346
+ * @param {(TNsPathPairs|Plugin|function|Object)} name - Method's name, plugin instance, function handler or plain object. See above for details
347
+ * @param {...*} [args] - One or more arguments passed as parameter to the handler
348
+ * @returns {Promise<*>} Returned value
315
349
  */
316
350
  callHandler = async (item, ...args) => {
317
351
  let result
@@ -356,8 +390,8 @@ class Bajo extends Plugin {
356
390
  * @param {boolean} [options.useBajo=false] - If true, add `bajo` to the running plugins too.
357
391
  * @param {string} [options.prefix=''] - Prepend glob pattern with prefix.
358
392
  * @param {boolean} [options.noUnderscore=true] - If true (default), matched file with name starts with underscore is ignored.
359
- * @param {any} [options.returnItems] - If true, each value of returned handler call will be saved as an object with running plugin name as its keys.
360
- * @returns {any}
393
+ * @param {*} [options.returnItems] - If true, each value of returned handler call will be saved as an object with running plugin name as its keys.
394
+ * @returns {*}
361
395
  */
362
396
  eachPlugins = async (handler, options = {}) => {
363
397
  if (isString(options)) options = { glob: options }
@@ -424,7 +458,7 @@ class Bajo extends Plugin {
424
458
  *
425
459
  * @method
426
460
  * @param {string} type - Format type. See {@link TBajoFormatType} for acceptable values.
427
- * @param {any} value - Value to format.
461
+ * @param {*} value - Value to format.
428
462
  * @param {string} [dataType] - Value's data type. See {@link TBajoDataType} for acceptable values.
429
463
  * @param {Object} [options={}] - Options.
430
464
  * @param {boolean} [options.withUnit=true] - Return with its unit appended.
@@ -432,7 +466,6 @@ class Bajo extends Plugin {
432
466
  * @returns {(Array|string)} Return string if `withUnit` is true. Otherwise is an array of `[value, unit, separator]`.
433
467
  */
434
468
  formatByType = (type, value, dataType, options = {}) => {
435
- const { defaultsDeep } = this.app.lib.aneka
436
469
  const { format } = this.getUnitFormat(options)
437
470
  const { withUnit = true } = options
438
471
  const lang = options.lang ?? this.config.lang
@@ -449,7 +482,7 @@ class Bajo extends Plugin {
449
482
  * Format value.
450
483
  *
451
484
  * @method
452
- * @param {any} value - Value to format.
485
+ * @param {*} value - Value to format.
453
486
  * @param {string} [type] - Data type to use. See {@link TBajoDataType} for acceptable values. If not provided, return the untouched value.
454
487
  * @param {Object} [options={}] - Options.
455
488
  * @param {string} [options.emptyValue=''] - Empty value to use if function resulted empty. Defaults to the one from your config.
@@ -460,7 +493,6 @@ class Bajo extends Plugin {
460
493
  * @returns {string} Formatted value.
461
494
  */
462
495
  format = (value, type, options = {}) => {
463
- const { defaultsDeep, isSet } = this.app.lib.aneka
464
496
  const { format } = this.config.intl
465
497
  const { emptyValue = format.emptyValue } = options
466
498
  const lang = options.lang ?? this.config.lang
@@ -585,7 +617,6 @@ class Bajo extends Plugin {
585
617
  * @returns {(Object|Array)} See above.
586
618
  */
587
619
  importPkg = async (...pkgs) => {
588
- const { defaultsDeep } = this.app.lib.aneka
589
620
  const result = {}
590
621
  const notFound = []
591
622
  let opts = { returnDefault: true, throwNotFound: false }
@@ -600,7 +631,7 @@ class Bajo extends Plugin {
600
631
  notFound.push(pkg)
601
632
  continue
602
633
  }
603
- const p = this.readJson(`${dir}/package.json`, opts.throwNotFound)
634
+ const p = await this.fromJson(`${dir}/package.json`, { readFromFile: true, throwNotFound: opts.throwNotFound })
604
635
  const mainFileOrg = dir + '/' + (p.main ?? get(p, 'exports.default', 'index.js'))
605
636
  let mainFile = resolvePath(mainFileOrg, os.platform() === 'win32')
606
637
  if (isEmpty(path.extname(mainFile))) {
@@ -675,35 +706,34 @@ class Bajo extends Plugin {
675
706
  }
676
707
 
677
708
  /**
678
- * Human friendly join array of items.
709
+ * Return human friendly joined array of items.
679
710
  *
680
711
  * @method
681
- * @param {any[]} array - Array to join
682
- * @param {(string|Object)} options - If provided and is a string, it will be used as separator.
712
+ * @param {Array<*>} input - Array to join
713
+ * @param {(string|Object)} [options={}] - If provided and is a string, it will be used as separator.
683
714
  * @param {string} [options.separator=', '] - Separator to use.
684
- * @param {string} [options.lastSeparator=and] - Text to use as the last separator.
715
+ * @param {string} [options.lastSeparator='and'] - Text to use as the last separator.
685
716
  * @returns {string}
686
717
  */
687
718
  join = (input = [], options = {}) => {
688
719
  const array = [...input]
689
720
  if (isString(options)) options = { separator: options }
690
- let { separator = ', ', lastSeparator = 'and', lang } = options
721
+ const { separator = ', ', lastSeparator = 'and', lang } = options
691
722
  const translate = (val) => {
692
723
  return this.t(val, { lang }).toLowerCase()
693
724
  }
694
725
  if (array.length === 0) return translate('none')
695
726
  if (array.length === 1) return array[0]
696
- lastSeparator = translate(lastSeparator)
697
727
  const last = (array.pop() ?? '').trim()
698
- return array.map(a => (a + '').trim()).join(separator) + ` ${lastSeparator} ${last}`
728
+ return array.map(a => (a + '').trim()).join(separator) + ` ${translate(lastSeparator)} ${last}`
699
729
  }
700
730
 
701
731
  /**
702
- * Return its numeric portion of a value.
732
+ * Get numeric portion and its unit from a string.
703
733
  *
704
734
  * @method
705
- * @param {string} [value=''] - Value to get its numeric portion.
706
- * @param {string} [defUnit=''] - Default unit if value doesn't have one.
735
+ * @param {string} [value=''] - Value to get its numeric portion
736
+ * @param {string} [defUnit=''] - Default unit if value doesn't have one
707
737
  * @returns {string}
708
738
  */
709
739
  numUnit = (value = '', defUnit = '') => {
@@ -714,27 +744,31 @@ class Bajo extends Plugin {
714
744
 
715
745
  /**
716
746
  * Read and parse file as config object. Supported types: `.js`, `.json` and `.yml/.yaml`.
717
- * More supports can be added using plugin. {@link https://github.com/ardhi/bajo-config|bajo-config} gives you additional supports for `.yml`, `.yaml` and `.toml` file.
747
+ * More supports can be added using plugin. {@link https://ardhi.github.io/bajo-config|bajo-config} gives you additional supports for `.yml`, `.yaml` and `.toml` file.
718
748
  *
719
749
  * If file extension is `.*`, it will be auto detected and parsed accordingly
720
750
  *
721
751
  * @method
722
752
  * @async
723
753
  * @param {string} file - File to read and parse.
724
- * @param {Object} [options={}] - Options.
725
- * @param {boolean} [options.ignoreError] - Any exception will be silently discarded.
726
- * @param {string} [options.ns] - If given, use this as the scope.
727
- * @param {string} [options.pattern] - If given and auto detection is on (extension is `.*`), it will be used for instead the default one.
728
- * @param {Object} [options.defValue={}] - Default value to use if value returned empty.
729
- * @param {Object} [options.parserOpts={}] - Parser setting.
730
- * @param {Object} [options.globOpts={}] - {@link https://github.com/mrmlnc/fast-glob|fast-glob} options.
754
+ * @param {Object} [options={}] - Options
755
+ * @param {boolean} [options.ignoreError] - Any exception will be silently discarded
756
+ * @param {string} [options.ns] - If provided, scoped to this namespace. Otherwise, the running plugin's namespace will be used
757
+ * @param {string} [options.baseNs] - If provided, it will be used as the base namespace for extending config from other plugins
758
+ * @param {string|string[]|boolean} [options.extend] - If provided, it will be used as the base namespace for extending config from other plugins. Set to `false` to disable extending
759
+ * @param {boolean} [options.checkOverride] - If `true` and `baseNs` is provided or `extend` is not `false`, check for override config in main plugin
760
+ * @param {boolean|string} [options.merge] - If `true`, config from other plugins will be merged into the original config. If `concat`, it performs array concatenation if object is an array. Otherwise, it will perform a deep defaults merge
761
+ * @param {string} [options.pattern] - If provided and auto detection is on (extension is `.*`), it will be used for instead the auto generated one
762
+ * @param {Object} [options.defValue={}] - Default value to use if value returned empty
763
+ * @param {Object} [options.parserOpts={}] - Parser options
764
+ * @param {Object} [options.globOpts={}] - {@link https://github.com/mrmlnc/fast-glob|fast-glob} options
731
765
  * @returns {Object}
732
766
  */
733
767
  readConfig = async (file, options = {}) => {
734
- const { parseObject } = this.app.lib
735
- const { defaultsDeep } = this.app.lib.aneka
736
- const { uniq, isString, isArray, findIndex, isPlainObject, merge } = this.app.lib._
737
- let { ns, baseNs, extend, checkOverride, merge: merged, pattern, ignoreError = true, defValue = {}, parserOpts = {}, globOpts = {}, handler, cache = {} } = options
768
+ let {
769
+ ns, baseNs, extend, checkOverride, merge: merged, pattern, ignoreError = true,
770
+ defValue = {}, parserOpts = {}, globOpts = {}, handler, cache = {}
771
+ } = options
738
772
 
739
773
  const getParseOptsArgs = (opts, orig) => {
740
774
  opts.parserOpts = opts.parserOpts ?? {}
@@ -746,6 +780,15 @@ class Bajo extends Plugin {
746
780
  else opts.parserOpts.args.push({ _orig: orig })
747
781
  }
748
782
 
783
+ const binder = (...args) => {
784
+ if (merged === 'concat') {
785
+ return mergeWith(...args, (objValue, srcValue) => {
786
+ if (isArray(objValue)) return objValue.concat(srcValue)
787
+ })
788
+ }
789
+ return merged ? merge(...args) : defaultsDeep(...args)
790
+ }
791
+
749
792
  const output = async (obj) => {
750
793
  let orig = parseObject(obj)
751
794
  if (!baseNs || extend === false) {
@@ -755,7 +798,7 @@ class Bajo extends Plugin {
755
798
  }
756
799
  const { suffix = '', keys = [] } = options
757
800
  let bases = this.app.getAllNs()
758
- if (isString(extend)) extend = extend.split(',').map(i => i.trim)
801
+ if (isString(extend)) extend = extend.split(',').map(i => i.trim())
759
802
  if (isArray(extend)) bases = [...extend, 'main']
760
803
  bases = uniq(bases)
761
804
  let ext = isArray(orig) ? [] : {}
@@ -776,14 +819,13 @@ class Bajo extends Plugin {
776
819
  if (!isEmpty(result)) orig = result
777
820
  }
778
821
  getParseOptsArgs(opts, orig)
779
- const binder = merged ? merge : defaultsDeep
780
822
  for (const base of bases) {
781
823
  if (!this.app[base]) continue
782
824
  options.sourceNs = base
783
825
  const fileExt = `${this.app[base].dir.pkg}/extend/${baseNs}/extend/${ns}${suffix}/${_path}`
784
- await this.runHook('bajo.extend:beforeReadConfig', fileExt, options)
826
+ await this.runHook('bajo.extend:beforeReadConfig', fileExt, orig, options)
785
827
  const result = parseObject(await this.readConfig(fileExt, { ...opts, extend: false, merge: false }))
786
- await this.runHook('bajo.extend:afterReadConfig', fileExt, result, options)
828
+ await this.runHook('bajo.extend:afterReadConfig', fileExt, orig, result, options)
787
829
  if (isEmpty(result)) continue
788
830
  if (isArray(result)) ext = [...result, ...ext]
789
831
  else ext = binder({}, result, ext)
@@ -800,23 +842,24 @@ class Bajo extends Plugin {
800
842
  if (cache.name) result = await this.app.cache.load(cache.name, cache.ttlDur)
801
843
  if (result) return result
802
844
  await this.runHook('bajo:beforeReadConfig', file, options)
803
- parserOpts.readFromFile = true
845
+ const readOpts = {
846
+ readFromFile: true,
847
+ throwNotFound: !ignoreError,
848
+ defValue,
849
+ parserOpts
850
+ }
804
851
  if (!ns) ns = this.ns
805
852
  file = resolvePath(this.app.getPluginFile(file))
806
853
  let ext = path.extname(file)
807
854
  const fname = path.dirname(file) + '/' + path.basename(file, ext)
808
855
  ext = ext.toLowerCase()
809
- if (['.js', '.json'].includes(ext)) {
810
- const item = find(this.app.configHandlers, { ext })
811
- return await output(await item.readHandler.call(this.app[ns], file, parserOpts))
812
- }
813
856
  if (!['', '.*'].includes(ext)) {
814
857
  const item = find(this.app.configHandlers, { ext })
815
858
  if (!item) {
816
859
  if (!ignoreError) throw this.error('cantParse%s', file, { code: 'BAJO_CONFIG_NO_PARSER' })
817
860
  return await output(defValue)
818
861
  }
819
- return await output(await item.readHandler.call(this.app[item.ns], file, parserOpts))
862
+ return await output(await item.readHandler.call(this.app[item.ns], file, readOpts))
820
863
  }
821
864
  const formats = this.app.getConfigFormats(true)
822
865
  const item = pattern ?? `${fname}.{${formats.join(',')}}`
@@ -834,147 +877,174 @@ class Bajo extends Plugin {
834
877
  continue
835
878
  }
836
879
  const _ns = ['.js', '.json'].includes(ext) ? ns : item.ns
837
- config = await item.readHandler.call(this.app[_ns], f, parserOpts)
880
+ config = await item.readHandler.call(this.app[_ns], f, readOpts)
838
881
  if (!isEmpty(config)) break
839
882
  }
840
883
  return await output(config)
841
884
  }
842
885
 
843
- /**
844
- * Read and parse json file.
845
- *
846
- * @method
847
- * @param {string} file - File to read.
848
- * @param {boolean} [thrownNotFound=false] - If `true`, silently ignore if file is not found.
849
- * @returns {Object}
850
- */
851
- readJson = (file, thrownNotFound = false) => {
852
- const { parseObject } = this.app.lib
853
- if (isPlainObject(thrownNotFound)) thrownNotFound = false
854
- if (!fs.existsSync(file) && thrownNotFound) throw this.error('notFound%s%s', this.t('file'), file)
855
- let resp
856
- try {
857
- resp = fs.readFileSync(file, 'utf8')
858
- } catch (err) {}
859
- if (isEmpty(resp)) return resp
860
- return parseObject(JSON.parse(resp))
861
- }
862
-
863
886
  /**
864
887
  * Read and parse JavaScript file.
865
888
  *
866
889
  * @async
867
890
  * @method
868
- * @param {string} file - File to read and parse.
869
- * @param {Object} [options={}] - Options.
870
- * @returns {Object} Parsed JavaScript object.
891
+ * @param {string} file - File to read and parse
892
+ * @param {boolean} [options.readFromFile=false] - Ignored for this method. Always read from file.
893
+ * @param {boolean} [options.throwNotFound=false] - If `true`, throw exception if file is not found
894
+ * @param {object} [options.defValue={}] - Default value to use if value returned empty
895
+ * @param {object} [options.parserOpts={}] - Options to be passed if file exports a function
896
+ * @returns {Object} Parsed JavaScript object
871
897
  */
872
898
  async fromJs (file, options = {}) {
873
- const args = options.args ?? []
899
+ if (isBoolean(options)) options = {}
900
+ options.defValue = options.defValue ?? {}
901
+ if (options.throwNotFound && !fs.existsSync(file)) throw this.error('notFound%s%s', this.t('file'), file)
874
902
  let mod = await importModule(file)
875
- if (isFunction(mod)) mod = await mod.call(this, ...args)
876
- return mod
903
+ if (isFunction(mod)) mod = await mod.call(this, options.parserOpts)
904
+ return isEmpty(mod) ? options.defValue : mod
877
905
  }
878
906
 
879
907
  /**
880
- * Read and parse JSON string or object.
908
+ * Parse JSON string or read and parse JSON file.
881
909
  *
882
- * @param {string} data - Filename to load from or JSON string to parse.
883
- * @param {Object} [options={}] - Options.
884
- * @returns {Object} Parsed JSON object.
910
+ * @async
911
+ * @method
912
+ * @param {string} text - Text to be parsed or file path to be read if `options.readFromFile` is `true`
913
+ * @param {object|boolean} [options={}] - Options object. If a boolean is provided, it will be treated as `options.readFromFile`
914
+ * @param {boolean} [options.readFromFile=false] - If `true`, `text` is treated as a file path
915
+ * @param {boolean} [options.throwNotFound=false] - If `true`, throw exception if file is not found
916
+ * @param {object} [options.defValue={}] - Default value to use if value returned empty
917
+ * @param {object} [options.parserOpts={}] - Options to be passed to `JSON.parse()`
918
+ * @returns {Object} Parsed object.
919
+ * @see Bajo#toJson
885
920
  */
886
- fromJson (data, options = {}) {
887
- const content = options.readFromFile ? fs.readFileSync(data, 'utf8') : data
888
- return JSON.parse(content)
921
+ async fromJson (text, options = {}) {
922
+ if (isBoolean(options)) options = { readFromFile: options }
923
+ options.defValue = options.defValue ?? {}
924
+ if (options.readFromFile && !fs.existsSync(text) && options.throwNotFound) throw this.error('notFound%s%s', this.t('file'), text)
925
+ const content = options.readFromFile ? fs.readFileSync(text, 'utf8') : text
926
+ const result = JSON.parse(content)
927
+ return isEmpty(result) ? options.defValue : result
889
928
  }
890
929
 
891
930
  /**
892
- * Parse YAML text
931
+ * Parse YAML text or read and parse YAML file.
893
932
  *
933
+ * @async
894
934
  * @method
895
- * @param {string} text - Text to be parsed
896
- * @param {object} options - Options object
935
+ * @param {string} text - Text to be parsed or file path to be read if `options.readFromFile` is `true`
936
+ * @param {object|boolean} [options={}] - Options object. If a boolean is provided, it will be treated as `options.readFromFile`
937
+ * @param {boolean} [options.readFromFile=false] - If `true`, `text` is treated as a file path
938
+ * @param {boolean} [options.throwNotFound=false] - If `true`, throw exception if file is not found
939
+ * @param {object} [options.parserOpts={}] - Options to be passed to `YAML.load()`
940
+ * @param {object} [options.defValue={}] - Default value to use if value returned empty
897
941
  * @returns {object} Parsed object
942
+ * @see Bajo#toYaml
898
943
  */
899
- fromYaml = (text, options = {}) => {
900
- const { fs } = this.app.lib
944
+ async fromYaml (text, options = {}) {
945
+ if (isBoolean(options)) options = { readFromFile: options }
946
+ options.defValue = options.defValue ?? {}
947
+ if (options.readFromFile && !fs.existsSync(text) && options.throwNotFound) throw this.error('notFound%s%s', this.t('file'), text)
901
948
  const content = options.readFromFile ? fs.readFileSync(text, 'utf8') : text
902
- return yaml.load(content)
949
+ const result = yaml.load(content)
950
+ return isEmpty(result) ? options.defValue : result
903
951
  }
904
952
 
905
953
  /**
906
- * Parse YML text. Alias for fromYaml.
954
+ * Parse YML text or read and parse YML file. Alias for {@link Bajo#fromYaml}.
907
955
  *
956
+ * @async
908
957
  * @method
909
- * @param {string} text - Text to be parsed
910
- * @param {object} options - Options object
958
+ * @param {string} text - Text to be parsed or file path to be read if `options.readFromFile` is `true`
959
+ * @param {object|boolean} [options={}] - Options object. If a boolean is provided, it will be treated as `options.readFromFile`
960
+ * @param {boolean} [options.readFromFile=false] - If `true`, `text` is treated as a file path
961
+ * @param {boolean} [options.throwNotFound=false] - If `true`, throw exception if file is not found
962
+ * @param {object} [options.defValue={}] - Default value to use if value returned empty
963
+ * @param {object} [options.parserOpts={}] - Options to be passed to `YAML.load()`
911
964
  * @returns {object} Parsed object
965
+ * @see Bajo#toYml
912
966
  */
913
- fromYml = (text, options = {}) => {
967
+ async fromYml (text, options = {}) {
914
968
  return this.fromYaml(text, options)
915
969
  }
916
970
 
917
971
  /**
918
- * Convert data to JSON string.
972
+ * Convert data to JSON string, optionally write to file if `options.writeToFile` is provided.
919
973
  *
974
+ * @async
920
975
  * @method
921
- * @param {Object} data - Data to convert to JSON string.
922
- * @param {Object} [options={}] - Options.
923
- * @param {boolean} [options.writeToFile=false] - If true, write the JSON string to a file.
924
- * @param {string} [options.saveAsFile] - The file path to save the JSON string if writeToFile is true.
925
- * @returns {string} JSON string
976
+ * @param {Object} data - Data to convert to JSON string
977
+ * @param {Object|string} [options={}] - Options object. If a string is provided, it will be treated as `options.writeToFile`
978
+ * @param {string} [options.writeToFile] - If not empty, write result to this file path instead of returning it as string
979
+ * @param {Object} [options.parserOpts={}] - Options for `JSON.stringify()`
980
+ * @returns {Promise<string|void>} JSON string or void if written to file
981
+ * @see Bajo#fromJson
926
982
  */
927
- toJson = (data, options = {}) => {
928
- const content = JSON.stringify(data, null, omit(options, ['writeToFile']))
929
- if (options.writeToFile) {
930
- fs.writeFileSync(options.saveAsFile, content, 'utf8')
983
+ async toJson (data, options = {}) {
984
+ if (isString(options)) options = { writeToFile: options }
985
+ options.parserOpts = options.parserOpts ?? {}
986
+ options.parserOpts.space = options.parserOpts.space ?? 2
987
+ const content = JSON.stringify(data, null, options.parserOpts)
988
+ if (isString(options.writeToFile)) {
989
+ fs.writeFileSync(options.writeToFile, content, 'utf8')
931
990
  return
932
991
  }
933
992
  return content
934
993
  }
935
994
 
936
995
  /**
937
- * Convert object to YAML string
996
+ * Convert object to YAML string, optionally write to file if `options.writeToFile` is provided.
938
997
  *
998
+ * @async
939
999
  * @method
940
1000
  * @param {object} object - Object to be converted
941
- * @param {object} options - Options object
942
- * @returns {string} YAML string
1001
+ * @param {object|string} [options] - Options object. If a string is provided, it will be treated as `options.writeToFile`
1002
+ * @param {string} [options.writeToFile] - If not empty, write result to this file path instead of returning it as string
1003
+ * @param {Object} [options.parserOpts={}] - Options for `YAML.dump()`
1004
+ * @returns {Promise<string|void>} YAML string or void if written to file
1005
+ * @see Bajo#fromYaml
943
1006
  */
944
- toYaml = (object, options = {}) => {
945
- const { omit } = this.app.lib._
946
- const { fs } = this.app.lib
947
- const content = yaml.dump(object, omit(options, ['writeToFile']))
1007
+ async toYaml (object, options = {}) {
1008
+ if (isString(options)) options = { writeToFile: options }
1009
+ options.parserOpts = options.parserOpts ?? {}
1010
+ const content = yaml.dump(object, options.parserOpts)
948
1011
  if (options.writeToFile) {
949
- fs.writeFileSync(options.saveAsFile, content, 'utf8')
950
- return
1012
+ if (isString(options.writeToFile)) {
1013
+ fs.writeFileSync(options.writeToFile, content, 'utf8')
1014
+ return
1015
+ }
951
1016
  }
952
1017
  return content
953
1018
  }
954
1019
 
955
1020
  /**
956
- * Convert object to YML string. Alias for toYaml.
957
- *
1021
+ * Convert object to YML string, optionally write to file if `options.writeToFile` is provided. Alias for {@link Bajo#toYaml}.
1022
+ * @async
958
1023
  * @method
959
1024
  * @param {object} object - Object to be converted
960
- * @param {object} options - Options object
961
- * @returns {string} YML string
1025
+ * @param {object|string} [options] - Options object. If a string is provided, it will be treated as `options.writeToFile`
1026
+ * @param {string} [options.writeToFile] - If not empty, write result to this file path instead of returning it as string
1027
+ * @param {Object} [options.parserOpts={}] - Options for `YAML.dump()`
1028
+ * @returns {Promise<string|void>} YAML string or void if written to file
1029
+ * @see Bajo#fromYml
962
1030
  */
963
- toYml = (object, options = {}) => {
1031
+ async toYml (object, options = {}) {
964
1032
  return this.toYaml(object, options)
965
1033
  }
966
1034
 
967
1035
  /**
968
- * Read all config files from path.
1036
+ * Read all forms of configuration files from file path.
1037
+ *
1038
+ * Internally, it will call {@link Bajo#readConfig} twice, first for the default config file and
1039
+ * second for the environment based config file. Then it will merge both results using `defaultsDeep`.
969
1040
  *
970
1041
  * @method
971
1042
  * @async
972
1043
  * @param {string} path - Base path to start looking config files.
973
- * @param {Object} [options={}] - Options.
1044
+ * @param {Object} [options={}] - Options. See {@link Bajo#readConfig} for more.
974
1045
  * @returns {Object} Merged configuration object.
975
1046
  */
976
1047
  readAllConfigs = async (path, options) => {
977
- const { defaultsDeep } = this.app.lib.aneka
978
1048
  let cfg = {}
979
1049
  let ext = {}
980
1050
  // default config file
@@ -993,7 +1063,14 @@ class Bajo extends Plugin {
993
1063
  }
994
1064
 
995
1065
  /**
996
- * Run named hook/event.
1066
+ * Run named {@link module:Hook}.
1067
+ *
1068
+ * If no hook found with the given name, it will return an empty array.
1069
+ * If a hook has `noWait` set to `true`, it will not wait for the hook to finish and will not return its result.
1070
+ *
1071
+ * > **Note**: Even though this method returns a result, it is not recommended to use the result for any purpose.
1072
+ * Use the result only for debugging or logging purposes. Hooks are designed to be fast, lightweight and mutate
1073
+ * arguments given to them so that the next hook can benefit from the changes.
997
1074
  *
998
1075
  * @method
999
1076
  * @async
@@ -1023,6 +1100,7 @@ class Bajo extends Plugin {
1023
1100
 
1024
1101
  /**
1025
1102
  * Get download directory. If doesn't exist, it will be created automatically.
1103
+ *
1026
1104
  * @method
1027
1105
  * @returns {string} Absolute path to the download directory
1028
1106
  */
@@ -1047,25 +1125,27 @@ class Bajo extends Plugin {
1047
1125
  * @returns {string} Full file path.
1048
1126
  */
1049
1127
  saveAsDownload = async (file, item, printSaved = true) => {
1050
- const { print } = this.app.bajo
1051
1128
  const fname = increment(`${this.getDownloadDir()}/${trim(file, '/')}`, { fs: true })
1052
1129
  await fs.writeFile(fname, item, 'utf8')
1053
- if (printSaved) print.succeed('savedAs%s', path.resolve(fname), { skipSilence: true })
1130
+ if (printSaved) this.print.succeed('savedAs%s', path.resolve(fname), { skipSilence: true })
1054
1131
  return fname
1055
1132
  }
1056
1133
 
1057
1134
  /**
1058
- * Read config using all registered config handlers. The first handler that returns a
1135
+ * Parse `input` using all registered config handlers. The first handler that returns a
1059
1136
  * valid object or array will be used.
1060
1137
  *
1138
+ * Use this method if you want to parse a text `input` that can be in any format supported by the registered config handlers.
1139
+ *
1061
1140
  * @method
1062
1141
  * @param {string} input - The input string to be processed by the config handlers.
1063
1142
  * @param {string[]} [exts] - Optional array of extensions to filter the config handlers. If provided, only handlers with matching extensions will be used.
1064
1143
  * @param {object} [options={}] - Options to be passed to the config handlers.
1065
1144
  * @returns {Object|Array|null} The result from the first successful config handler, or null if none succeed.
1066
1145
  */
1067
- readAsConfig = async (input, exts, options = {}) => {
1146
+ parseWithConfig = async (input, exts, options = {}) => {
1068
1147
  let result
1148
+ options.readFromFile = false
1069
1149
  const handlers = exts ? this.app.configHandlers.filter(h => exts.includes(h.ext)) : this.app.configHandlers
1070
1150
  for (const handler of handlers) {
1071
1151
  if (result) break