bajo
Version:
The ultimate framework for whipping up massive apps in no time
1,084 lines (1,031 loc) • 41.6 kB
JavaScript
import Plugin from './plugin.js'
import increment from 'add-filename-increment'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import emptyDir from 'empty-dir'
import lodash from 'lodash'
import { createRequire } from 'module'
import getGlobalPath from 'get-global-path'
import fastGlob from 'fast-glob'
import querystring from 'querystring'
import importModule from '../lib/import-module.js'
import logLevels from '../lib/log-levels.js'
import { types as formatTypes, formats } from '../lib/formats.js'
import aneka from 'aneka'
import {
buildBaseConfig,
buildExtConfig,
buildPlugins,
collectConfigHandlers,
bootOrder,
bootPlugins,
exitHandler
} from './helper/bajo.js'
const require = createRequire(import.meta.url)
const {
isFunction, map, isObject,
trim, filter, isEmpty, orderBy, pullAt, find, camelCase,
cloneDeep, isPlainObject, isArray, isString, omit, keys, indexOf,
last, get, has, values, dropRight, pick
} = lodash
const { resolvePath, currentLoc } = aneka
/**
* The Core. The main engine. The one and only plugin that control app's boot process and
* making sure all other plugins working nicely.
*
* @class
*/
class Bajo extends Plugin {
/**
* @param {App} app - App instance. Usefull to call app method inside a plugin
*/
constructor (app) {
super('bajo', app)
this.alias = 'bajo'
this.whiteSpace = [' ', '\t', '\n', '\r']
/**
* Config object
*
* @type {Object}
* @see {@tutorial config}
*/
this.config = {}
app.configHandlers = [
{ ext: '.js', readHandler: this.fromJs },
{ ext: '.json', readHandler: this.fromJson, writeHandler: this.toJson }
]
this.hooks = []
}
/**
* Initialization:
*
* 1. Building {@link module:Helper/Bajo.buildBaseConfig|base config}
* 2. {@link module:Helper/Bajo.buildPlugins|Building plugins}
* 3. Collect all {@link module:Helper/Bajo.collectConfigHandlers|config handler}
* 4. Building {@link module:Helper/Bajo.buildExtConfig|extra config}
* 5. Setup {@link module:Helper/Bajo.bootOrder|boot order}
* 6. {@link module:Helper/Bajo.bootPlugins|Boot loaded plugins}
* 7. Attach {@link module:Helper/Bajo.exitHandler|exit handlers}
*
* @method
* @async
*/
init = async () => {
await buildBaseConfig.call(this)
await collectConfigHandlers.call(this)
await buildExtConfig.call(this)
await buildPlugins.call(this)
await bootOrder.call(this)
await bootPlugins.call(this)
await exitHandler.call(this)
if (this.app.bajoSpatial) {
this.anekaSpatial = await this.importPkg('bajoSpatial:aneka-spatial')
}
}
breakNsPathFromFile = ({ file = '', dir = '', ns, suffix = '', getType } = {}) => {
let item = file.replace(dir + suffix, '')
let type
if (getType) {
const items = item.split('/')
type = items.shift()
item = items.join('/')
}
item = item.slice(0, item.length - path.extname(item).length)
let [name, _path] = item.split('@')
if (!_path) {
_path = name
name = ns
}
_path = camelCase(_path)
const names = map(name.split('.'), n => camelCase(n))
const [_ns, subNs] = names
return { ns: _ns, subNs, path: _path, fullNs: names.join('.'), type }
}
/**
* Name based ```{ns}:{path}``` format
* @typedef {string} TNsPathPairs
* @see TNsPathResult
* @see Bajo#buildNsPath
* @see Bajo#breakNsPath
*/
/**
* Build ns/path pairs
*
* @method
* @param {object} [options={}] - Options object
* @param {string} [options.ns=''] - Namespace
* @param {string} [options.subNs] - Sub namespace
* @param {string} [options.subSubNs] - Sub sub namespace
* @param {string} [options.path] - Path
* @returns {TNsPathPairs} - Ns/path pairs
*/
buildNsPath = ({ ns = '', subNs, subSubNs, path } = {}) => {
if (subNs) ns += '.' + subNs
if (subSubNs) ns += '.' + subSubNs
return `${ns}:${path}`
}
/**
* Object returned by {@link Bajo#breakNsPath|bajo:breakNsPath}
*
* @typedef {Object} TNsPathResult
* @property {string} ns - Namespace
* @property {string} [subNs] - Sub namespace
* @property {string} [subSubNs] - Sub of sub namespace
* @property {string} path - Path without query string or hash
* @property {string} fullPath - Full path, including query string and hash
* @see TNsPathPairs
* @see Bajo#buildNsPath
* @see Bajo#breakNsPath
*/
/**
* Break name to its namespace & path infos
*
* @method
* @param {(TNsPathPairs|string)} name - Name to break
* @param {boolean} [checkNs=true] - If ```true``` (default), namespace will be checked for its validity
* @returns {TNsPathResult}
*/
breakNsPath = (name = '', checkNs = true) => {
let [ns, ...path] = name.split(':')
const fullNs = ns
let subNs
let subSubNs
path = path.join(':')
if (path.startsWith('//')) {
return { path: name } // for: http:// etc
}
[ns, subNs, subSubNs] = ns.split('.')
if (checkNs) {
if (!this.app[ns]) {
const plugin = this.getPlugin(ns)
if (plugin) ns = plugin.ns
}
if (!this.app[ns]) throw this.error('unknownPluginOrNotLoaded%s')
}
let qs
[path, qs] = path.split('?')
qs = querystring.parse(qs) ?? {}
// normalize path
const parts = path.split('/')
const realParts = []
const params = {}
for (const idx in parts) {
const part = parts[idx]
if (part[0] !== ':' || part.indexOf('|') === -1) {
realParts.push(part)
continue
}
const [key, val] = part.split('|')
parts[idx] = key
params[key.slice(1)] = val
realParts.push(val)
}
path = parts.join('/')
const realPath = realParts.join('/')
let fullPath = path
if (!isEmpty(qs)) fullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) }))
let realFullPath = realPath
if (!isEmpty(qs)) realFullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) }))
return { ns, path, subNs, subSubNs, qs, fullPath, fullNs, realPath, realFullPath }
}
/**
* Method to transform config's array or object into an array of collection.
*
* @method
* @async
* @param {Object} options - Options
* @param {string} [options.ns] - Namespace. If not provided, defaults to ```bajo```
* @param {function} [options.handler] - Handler to call while building the collection item
* @param {string[]} [options.dupChecks=[]] - Array of keys to check for duplicates
* @param {string} options.container - Key used as container name
* @param {boolean} [options.useDefaultName=true] - If true (default) and ```name``` key is not provided, returned collection will be named ```default```
* @fires bajo:beforeBuildCollection
* @fires bajo:afterBuildCollection
* @returns {Object[]} The collection
*/
buildCollections = async (options = {}) => {
const { parseObject } = this.app.lib
let { ns, handler, dupChecks = [], container, useDefaultName = true, noDefault = true } = options
if (!ns) ns = this.ns
const cfg = this.app[ns].getConfig()
let items = get(cfg, container, [])
if (!isArray(items)) items = [items]
this.app[ns].log.trace('collecting%s', this.t(container))
/**
* Run before collection is built
*
* @global
* @event bajo:beforeBuildCollection
* @param {string} container
* @see {@tutorial hook}
* @see Bajo#buildCollections
*/
await this.runHook(`${ns}:beforeBuildCollection`, container)
const deleted = []
for (const index in items) {
const item = parseObject(items[index])
if (useDefaultName) {
if (!has(item, 'name')) {
if (find(items, { name: 'default' })) throw this.app[ns].error('collExists%s', 'default')
else item.name = 'default'
}
}
this.app[ns].log.trace('- %s', item.name)
const result = await handler.call(this.app[ns], { item, index, cfg })
if (result) items[index] = result
else if (result === false) deleted.push(index)
if (this.app.applet && item.skipOnApplet && !deleted.includes(index)) deleted.push(index)
}
if (deleted.length > 0) pullAt(items, deleted)
// check for duplicity
if (dupChecks.length > 0) {
const checkers = []
for (const c of items) {
const checker = JSON.stringify(pick(c, dupChecks))
if (checkers.includes(checker)) this.app[ns].fatal('oneOrMoreSharedTheSame%s%s', container, this.join(dupChecks.filter(i => !isFunction(i))))
}
}
if (!noDefault && !items.find(item => item.name === 'default')) this.app[ns].fatal('missing%s%s', 'default', container)
/**
* Run after collection is built
*
* @global
* @event bajo:afterBuildCollection
* @param {string} container
* @param {Object[]} items
* @see {@tutorial hook}
* @see Bajo#buildCollections
*/
await this.runHook(`${ns}:afterBuildCollection`, container, items)
this.app[ns].log.debug('collected%s%d', this.t(container), items.length)
return items
}
/**
* Calling any plugin's method by its name:
*
* - If name is a string, the corresponding plugin's method will be called with passed args as its parameters
* - 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
* - If name is a function, this function will be run under scope with the remaining args
* - If name is an object and has ```handler``` key in it, this function handler will be instead
*
* @method
* @async
* @param {(TNsPathPairs|Object|function)} name - Method's name, function handler, plain object or plugin instance
* @param {...any} [args] - One or more arguments passed as parameter to the handler
* @returns {any} Returned value
*/
callHandler = async (item, ...args) => {
let result
let scope = this
if (item instanceof Plugin) {
scope = item
item = args.shift()
}
if (isString(item)) {
if (item.startsWith('applet:') && this.app.applets.length > 0) {
const [, ns, path] = item.split(':')
const applet = find(this.app.applets, a => (a.ns === ns || a.alias === ns))
if (applet && this.app.bajoCli) result = await this.app.bajoCli.runApplet(applet, path, ...args)
} else {
const [ns, method, ...params] = item.split(':')
const fn = this.getMethod(`${ns}:${method}`)
if (fn) {
if (params.length > 0) args.unshift(...params)
result = await fn(...args)
}
}
} else if (isFunction(item)) {
result = await item.call(scope, ...args)
} else if (isPlainObject(item) && isFunction(item.handler)) {
result = await item.handler.call(scope, ...args)
}
return result
}
/**
* This function iterates through all loaded plugins and call the provided handler scoped as the running plugin.
* And an object with the following key serves as its parameter:
*
* - ```file```: file matched the glob pattern
* - ```dir```: plugin's base directory
*
* @method
* @async
* @param {function} handler - Function handler. Can be an async function. Scoped to the running plugin
* @param {(string|Object)} [options={}] - Options. If a string is provided, it serves as the glob pattern, otherwise:
* @param {(string|string[])} [options.glob] - Glob pattern. If provided,
* @param {boolean} [options.useBajo=false] - If true, add ```bajo``` to the running plugins too
* @param {string} [options.prefix=''] - Prepend glob pattern with prefix
* @param {boolean} [options.noUnderscore=true] - If true (default), matched file with name starts with underscore is ignored
* @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
* @returns {any}
*/
eachPlugins = async (handler, options = {}) => {
if (typeof options === 'string') options = { glob: options }
const { glob, useBajo, prefix = '', noUnderscore = true, returnItems } = options
const pluginPkgs = useBajo ? [...cloneDeep(this.app.pluginPkgs), 'bajo'] : this.app.pluginPkgs
const result = {}
for (const pkgName of pluginPkgs) {
const ns = camelCase(pkgName)
let r
if (glob) {
const base = prefix === '' ? `${this.app[ns].dir.pkg}/extend` : `${this.app[ns].dir.pkg}/extend/${prefix}`
let opts = isString(glob) ? { pattern: [glob] } : glob
let pattern = opts.pattern ?? []
if (isString(pattern)) pattern = [pattern]
opts = omit(opts, ['pattern'])
for (const i in pattern) {
if (!path.isAbsolute(pattern[i])) pattern[i] = `${base}/${pattern[i]}`
}
const files = await fastGlob(pattern, opts)
for (const f of files) {
if (path.basename(f)[0] === '_' && noUnderscore) continue
const resp = await handler.call(this.app[ns], { file: f, dir: base })
if (resp === false) break
else if (resp === undefined) continue
else {
result[ns] = result[ns] ?? {}
result[ns][f] = resp
}
}
} else {
r = await handler.call(this.app[ns], { dir: this.app[ns].dir.pkg })
if (r === false) break
else if (r === undefined) continue
else result[ns] = r
}
}
if (returnItems) {
const data = []
for (const r in result) {
for (const f in result[r]) {
data.push(result[r][f])
}
}
return data
}
return result
}
/**
* Object returned by {@link Bajo#getUnitFormat|bajo:getUnitFormat}
*
* @typedef {Object} TBajoFormatResult
* @property {string} unitSys - Unit system
* @property {Object} format - Format object
* @see Bajo#getUnitFormat
*/
/**
* Get unit format
*
* @method
* @param {Object} [options={}] - Options
* @param {string} [options.lang] - Language to use. Defaults to the one you set in config
* @param {string} [options.unitSys] - Unit system to use. Defaults to language's unit system or ```metric``` if unspecified
* @returns {TBajoFormatResult} - Returned value
*/
getUnitFormat = (options = {}) => {
const lang = options.lang ?? this.config.lang
let unitSys = options.unitSys ?? this.config.intl.unitSys[lang] ?? 'metric'
if (!['imperial', 'nautical', 'metric'].includes(unitSys)) unitSys = 'metric'
return { unitSys, format: formats[unitSys] }
}
/**
* Format value by type
*
* @method
* @param {string} type - Format type. See {@link TBajoFormatType} for acceptable values
* @param {any} value - Value to format
* @param {string} [dataType] - Value's data type. See {@link TBajoDataType} for acceptable values
* @param {Object} [options={}] - Options
* @param {boolean} [options.withUnit=true] - Return with its unit appended
* @param {string} [options.lang] - Format value according to this language. Defaults to the one you set in config
* @returns {(Array|string)} Return string if ```withUnit``` is true. Otherwise is an array of ```[value, unit, separator]```
*/
formatByType = (type, value, dataType, options = {}) => {
const { defaultsDeep } = this.app.lib.aneka
const { format } = this.getUnitFormat(options)
const { withUnit = true } = options
const lang = options.lang ?? this.config.lang
value = format[`${type}Fn`](value)
const unit = format[`${type}Unit`]
const sep = format[`${type}UnitSep`] ?? ' '
if (!withUnit) return [value, unit, sep]
const setting = defaultsDeep(options[dataType], this.config.intl.format[dataType])
value = new Intl.NumberFormat(lang, setting).format(value)
return `${value}${sep}${unit}`
}
/**
* Format value
*
* @method
* @param {any} value - Value to format
* @param {string} [type] - Data type to use. See {@link TBajoDataType} for acceptable values. If not provided, return the untouched value
* @param {Object} [options={}] - Options
* @param {string} [options.emptyValue=''] - Empty value to use if function resulted empty. Defaults to the one from your config
* @param {boolean} [options.withUnit=true] - Return with its unit appended
* @param {string} [options.lang] - Format value according to this language. Defaults to the one you set in config
* @param {string} [options.latitude] - If Bajo Spatial is loaded and data type is a double or float, then format it as latitude in degree, minute, second
* @param {string} [options.longitude] - If Bajo Spatial is loaded and data type is a double or float, then format it as longitude in degree, minute, second
* @returns {string} Formatted value
*/
format = (value, type, options = {}) => {
const { defaultsDeep, isSet } = this.app.lib.aneka
const { format } = this.config.intl
const { emptyValue = format.emptyValue } = options
const lang = options.lang ?? this.config.lang
options.withUnit = options.withUnit ?? true
let valueFormatted
if ([undefined, null, ''].includes(value)) return emptyValue
if (type === 'auto') {
if (value instanceof Date) type = 'datetime'
}
if (['float', 'double'].includes(type) && this.anekaSpatial) {
const { latToDms, lngToDms } = this.anekaSpatial
if (options.latitude) return latToDms(value)
if (options.longitude) return lngToDms(value)
}
if (['integer', 'smallint', 'float', 'double'].includes(type)) {
value = ['integer', 'smallint'].includes(type) ? parseInt(value) : parseFloat(value)
if (isNaN(value)) return emptyValue
for (const u of formatTypes) {
if (options[u]) valueFormatted = this.formatByType(u, value, type, options)
}
}
if (['integer', 'smallint'].includes(type)) {
const setting = defaultsDeep(options.integer, format.integer)
value = new Intl.NumberFormat(lang, setting).format(Math.round(value))
return valueFormatted && options.withUnit ? valueFormatted : value
}
if (['float', 'double'].includes(type)) {
const setting = defaultsDeep(options[type], format[type])
value = new Intl.NumberFormat(lang, setting).format(value)
return valueFormatted && options.withUnit ? valueFormatted : value
}
if (['datetime', 'date'].includes(type)) {
const setting = defaultsDeep(options[type], format[type])
return new Intl.DateTimeFormat(lang, setting).format(new Date(value))
}
if (['time'].includes(type)) {
const setting = defaultsDeep(options.time, format.time)
return new Intl.DateTimeFormat(lang, setting).format(new Date(`1970-01-01T${value}Z`))
}
if (['array'].includes(type)) return value.join(', ')
if (['object'].includes(type)) return JSON.stringify(value)
if (['boolean'].includes(type) && isSet(value)) return value ? this.t('true', { lang }) : this.t('false', { lang })
return value
}
/**
* Format text according using sprintf with extra ability to run its arguments through a serie of modifiers
*
* @param {string} text - Text to be formatted
* @param {...any} args - Argumennts
* @returns {string} Formatted text
*/
/**
* Get NPM global module directory
*
* @method
* @param {string} [pkgName] - If provided, return this package global directory. Otherwise the npm global module directory
* @param {boolean} [silent=true] - Set to ```false``` to throw exception in case of error. Otherwise silently returns undefined
* @returns {string}
*/
getGlobalModuleDir = (pkgName, silent = true) => {
let nodeModulesDir = process.env.BAJO_GLOBAL_MODULE_DIR
if (!nodeModulesDir) {
const npmPath = getGlobalPath('npm')
if (!npmPath) {
if (silent) return
throw this.error('cantLocateNpmGlobalDir', { code: 'BAJO_CANT_LOCATE_NPM_GLOBAL_DIR' })
}
nodeModulesDir = dropRight(resolvePath(npmPath).split('/'), 1).join('/')
process.env.BAJO_GLOBAL_MODULE_DIR = nodeModulesDir
}
if (!pkgName) return nodeModulesDir
const dir = `${nodeModulesDir}/${pkgName}`
if (!fs.existsSync(dir)) {
if (silent) return
throw this.error('cantLocateGlobalDir%s', pkgName, { code: 'BAJO_CANT_LOCATE_MODULE_GLOBAL_DIR' })
}
return dir
}
/**
* Get class method by name
*
* @method
* @param {string} name - Name in format ```ns:methodName```
* @param {boolean} [thrown=true] - If ```true``` (default), throw exceptiom in case of error
* @returns {function} Class method
*/
getMethod = (name = '', thrown = true) => {
const { ns, path } = this.breakNsPath(name, thrown)
const method = get(this.app, `${ns}.${path}`)
if (method && isFunction(method)) return method
if (thrown) throw this.error('cantFindMethod%s', name)
}
/**
* Get module directory, locally and globally
*
* @method
* @param {string} pkgName - Package name to find
* @param {string} base - Provide base name if ```pkgName``` is a module under ```base```'s package name
* @returns {string} Return absolute package directory
*/
getModuleDir = (pkgName, base) => {
const { findDeep } = this.app.lib
if (pkgName === 'main') return resolvePath(this.app.dir)
if (base === 'main') base = this.app.dir
else if (this && this.app && this.app[base]) base = this.app[base].pkgName
const pkgPath = pkgName + '/package.json'
const paths = require.resolve.paths(pkgPath)
const gdir = this.getGlobalModuleDir()
paths.unshift(gdir)
paths.unshift(resolvePath(path.join(this.app.dir, 'node_modules')))
let dir = findDeep(pkgPath, paths)
if (base && !dir) dir = findDeep(`${base}/node_modules/${pkgPath}`, paths)
if (!dir) return null
return resolvePath(path.dirname(dir))
}
/**
* Get plugin data directory
*
* @method
* @param {string} name - Plugin name (namespace) or alias
* @param {boolean} [ensureDir=true] - Set ```true``` (default) to ensure directory is existed
* @returns {string}
*/
getPluginDataDir = (name, ensureDir = true) => {
const plugin = this.getPlugin(name)
const dir = `${this.app.bajo.dir.data}/plugins/${plugin.ns}`
if (ensureDir) fs.ensureDirSync(dir)
return dir
}
/**
* Resolve file path from:
*
* - local/absolute file
* - TNsPath (```myPlugin:/path/to/file.txt```)
* - file under node_modules, e.g. ```myPlugin:node_modules/some-package/file.txt```
*
* @method
* @param {string} file - File path, see above for supported types
* @returns {string} Resolved file path
*/
getPluginFile = (file) => {
if (!this) return file
if (file[0] === '.') file = `${currentLoc(import.meta).dir}/${trim(file.slice(1), '/')}`
if (file.includes(':')) {
if (file.slice(1, 2) === ':') return file // windows fs
const { ns, path } = this.breakNsPath(file)
if (ns !== 'file' && this && this.app && this.app[ns] && ns.length > 1) {
file = `${this.app[ns].dir.pkg}${path}`
if (path.startsWith('node_modules/')) {
file = `${this.app[ns].dir.pkg}/${path}`
if (!fs.existsSync(file)) file = `${this.app[ns].dir.pkg}/../${path.slice('node_modules/'.length)}`
}
}
}
return file
}
/**
* Get plugin by name
*
* @method
* @param {string} name - Plugin name/namespace or alias
* @param {boolean} [silent] - If ```true```, silently return undefined even on error
* @returns {Object} Plugin object
*/
getPlugin = (name, silent) => {
if (!this.app[name]) {
// alias?
let plugin
for (const key in this.app) {
const item = this.app[key]
if (item instanceof Plugin && (item.alias === name || item.pkgName === name)) {
plugin = item
break
}
}
if (!plugin) {
if (silent) return false
throw this.error('pluginWithNameAliasNotLoaded%s', name)
}
name = plugin.ns
}
return this.app[name]
}
/**
* Import file/module from any loaded plugins.
*
* Method proxy from {@link module:Lib.importModule}
*
* @method
* @async
* @see module:Lib.importModule
*/
importModule = async (file, { asDefaultImport, asHandler, noCache } = {}) => {
return await importModule.call(this, file, { asDefaultImport, asHandler, noCache })
}
/**
* Import one or more packages belongs to a plugin.
*
* If the last arguments passed is an object, this object serves as options object:
* - ```returnDefault```: should return package's default export. Defaults to ```true```
* - ```throwNotFound```: should throw if package is not found. Defaults to ```false```
* - ```noCache```: always use fresh import. Defaults to ```false```
* - ```asObject```: see below. Defaults to ```false```
*
* Return value:
* - if ```options.asObject``` is ```true``` (default ```false```), return as object with package's names as it's keys
* - Otherwise depends on how many parameters are provided, it should return the named package or an array of packages
*
* Example: you want to import ```delay``` and ```chalk``` from ```bajo``` plugin because you want to use it in your code
* ```javascript
* const { importPkg } from this.app.bajo
* const [delay, chalk] = await importPkg('bajo:delay', 'bajo:chalk')
*
* await delay(1000)
* ...
* ```
*
* @method
* @async
* @param {...TNsPathPairs} pkgs - One or more packages in format ```{ns}:{packageName}```
* @returns {(Object|Array)} See above
*/
importPkg = async (...pkgs) => {
const { defaultsDeep } = this.app.lib.aneka
const result = {}
const notFound = []
let opts = { returnDefault: true, throwNotFound: false }
if (isPlainObject(last(pkgs))) {
opts = defaultsDeep(pkgs.pop(), opts)
}
for (let pkg of pkgs) {
if (pkg.indexOf(':') === -1) pkg = `bajo:${pkg}`
const { ns, path: name } = this.breakNsPath(pkg)
const dir = this.getModuleDir(name, ns)
if (!dir) {
notFound.push(pkg)
continue
}
const p = this.readJson(`${dir}/package.json`, opts.throwNotFound)
const mainFileOrg = dir + '/' + (p.main ?? get(p, 'exports.default', 'index.js'))
let mainFile = resolvePath(mainFileOrg, os.platform() === 'win32')
if (isEmpty(path.extname(mainFile))) {
if (fs.existsSync(`${mainFileOrg}/index.js`)) mainFile += '/index.js'
else mainFile += '.js'
}
if (opts.noCache) mainFile += `?_=${Date.now()}`
let mod = await import(mainFile)
if (opts.returnDefault && has(mod, 'default')) {
mod = mod.default
if (opts.returnDefault && has(mod, 'default')) mod = mod.default
}
result[name] = mod
}
if (notFound.length > 0 && opts.throwNotFound) throw this.error('cantFind%s', this.join(notFound))
if (opts.asObject) return result
if (pkgs.length === 1) return result[keys(result)[0]]
return values(result)
}
/**
* Check whether a directory is empty or not. More info please {@link https://github.com/gulpjs/empty-dir|check here}.
*
* @method
* @async
* @param {(string|TNsPathPairs)} dir - Directory to check
* @param {function} filterFn - Filter function to filter out files that cause false positives.
* @returns {boolean}
*/
isEmptyDir = async (dir, filterFn) => {
dir = resolvePath(this.getPluginFile(dir))
await fs.exists(dir)
return await emptyDir(dir, filterFn)
}
/**
* Check whether log level is within log's app current level
*
* @method
* @param {string} level - Level to check. See {@link TLogLevels} for more
* @returns {boolean}
*/
isLogInRange = (level) => {
const levels = keys(logLevels)
const logLevel = indexOf(levels, this.app.bajo.config.log.level)
return indexOf(levels, level) >= logLevel
}
isValidAppPlugin = (file, type, returnPkg) => {
if (isObject(file)) return get(file, 'bajo.type') === type
file = resolvePath(file)
if (path.basename(file) !== 'package.json') file += '/package.json'
try {
const pkg = fs.readJsonSync(file)
const valid = get(pkg, 'bajo.type') === type
if (valid) return returnPkg ? pkg : valid
return false
} catch (err) {
return false
}
}
/**
* Check whether directory is a valid Bajo app
*
* @method
* @param {string} dir - Directory to check
* @param {boolean} [returnPkg] - Set ```true``` to return its package.json content
* @returns {(boolean|Object)}
*/
isValidApp = (dir, returnPkg) => {
if (!dir) dir = this.app.dir
return this.isValidAppPlugin(dir, 'app', returnPkg)
}
/**
* Check whether directory is a valid Bajo plugin
*
* @method
* @param {string} dir - Directory to check
* @param {boolean} [returnPkg] - Set ```true``` to return its package.json content
* @returns {(boolean|Object)}
*/
isValidPlugin = (dir, returnPkg) => {
if (!dir) dir = this.app.dir
return this.isValidAppPlugin(dir, 'plugin', returnPkg)
}
/**
* Human friendly join array of items.
*
* @method
* @param {any[]} array - Array to join
* @param {(string|Object)} options - If provided and is a string, it will be used as separator
* @param {string} [options.separator=', '] - Separator to use
* @param {string} [options.lastSeparator=and] - Text to use as the last separator
* @returns {string}
*/
join = (input = [], options = {}) => {
const array = [...input]
if (isString(options)) options = { separator: options }
let { separator = ', ', lastSeparator = 'and', lang } = options
const translate = (val) => {
return this.t(val, { lang }).toLowerCase()
}
if (array.length === 0) return translate('none')
if (array.length === 1) return array[0]
lastSeparator = translate(lastSeparator)
const last = (array.pop() ?? '').trim()
return array.map(a => (a + '').trim()).join(separator) + ` ${lastSeparator} ${last}`
}
/**
* Return its numeric portion of a value
*
* @method
* @param {string} [value=''] - Value to get its numeric portion
* @param {string} [defUnit=''] - Default unit if value doesn't have one
* @returns {string}
*/
numUnit = (value = '', defUnit = '') => {
const num = value.match(/\d+/g)
const unit = value.match(/[a-zA-Z]+/g)
return `${num[0]}${isEmpty(unit) ? defUnit : unit[0]}`
}
/**
* Read and parse file as config object. Supported types: ```.js``` and ```.json```.
* 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
*
* If file extension is ```.*```, it will be auto detected and parsed accordingly
*
* @method
* @async
* @param {string} file - File to read and parse
* @param {Object} [options={}] - Options
* @param {boolean} [options.ignoreError] - Any exception will be silently discarded
* @param {string} [options.ns] - If given, use this as the scope
* @param {string} [options.pattern] - If given and auto detection is on (extension is ```.*```), it will be used for instead the default one
* @param {Object} [options.defValue={}] - Default value to use if value returned empty
* @param {Object} [options.parserOpts={}] - Parser setting
* @param {Object} [options.globOpts={}] - {@link https://github.com/mrmlnc/fast-glob|fast-glob} options
* @returns {Object}
*/
readConfig = async (file, options = {}) => {
const { parseObject } = this.app.lib
const { defaultsDeep } = this.app.lib.aneka
const { uniq, isString, isArray, findIndex, isPlainObject, merge } = this.app.lib._
let { ns, baseNs, extend, checkOverride, merge: merged, pattern, ignoreError = true, defValue = {}, parserOpts = {}, globOpts = {}, handler } = options
const getParseOptsArgs = (opts, orig) => {
opts.parserOpts = opts.parserOpts ?? {}
opts.parserOpts.args = opts.parserOpts.args ?? []
const idx = findIndex(opts.parserOpts.args, item => {
return isPlainObject(item) && Object.keys(item)[0] === '_orig'
})
if (idx > -1) opts.parserOpts.args[idx] = { _orig: orig }
else opts.parserOpts.args.push({ _orig: orig })
}
const output = async (obj) => {
let orig = parseObject(obj)
if (!baseNs || extend === false) {
await this.runHook('bajo:afterReadConfig', file, orig, options)
return orig
}
const { suffix = '', keys = [] } = options
let bases = this.app.getAllNs()
if (isString(extend)) extend = extend.split(',').map(i => i.trim)
if (isArray(extend)) bases = [...extend, 'main']
bases = uniq(bases)
let ext = isArray(orig) ? [] : {}
const dir = this.app[ns].dir.pkg
let [names, _path] = file.split(':')
if (file.slice(0, names.length + 1) !== `${ns}:`) _path = file.slice(dir.length + 1)
if (_path.startsWith('extend/')) _path = _path.slice(7)
if (_path.startsWith(`${baseNs}/`)) _path = _path.slice(baseNs.length + 1)
_path = _path.slice(0, -(path.extname(_path).length)) + '.*'
// check for override? Override only exists in main plugin
const opts = omit(options, ['suffix', 'keys', 'extend'])
if (checkOverride) {
getParseOptsArgs(opts, orig)
const fileExt = `${this.app.main.dir.pkg}/extend/${baseNs}/override/${ns}${suffix}/${_path}`
await this.runHook('bajo.override:beforeReadConfig', fileExt, options)
const result = parseObject(await this.readConfig(fileExt, { ...opts, extend: false, checkOverride: false, merge: false }))
await this.runHook('bajo.override:afterReadConfig', fileExt, result, options)
if (!isEmpty(result)) orig = result
}
getParseOptsArgs(opts, orig)
const binder = merged ? merge : defaultsDeep
for (const base of bases) {
if (!this.app[base]) continue
options.sourceNs = base
const fileExt = `${this.app[base].dir.pkg}/extend/${baseNs}/extend/${ns}${suffix}/${_path}`
await this.runHook('bajo.extend:beforeReadConfig', fileExt, options)
const result = parseObject(await this.readConfig(fileExt, { ...opts, extend: false, merge: false }))
await this.runHook('bajo.extend:afterReadConfig', fileExt, result, options)
if (isEmpty(result)) continue
if (isArray(result)) ext = [...result, ...ext]
else ext = binder({}, result, ext)
}
delete options.sourceNs
let result = isArray(orig) ? [...orig, ...ext] : binder({}, keys.length > 0 ? pick(ext, keys) : ext, orig)
if (handler) result = await this.callHandler(this.app[ns], handler, result)
await this.runHook('bajo:afterReadConfig', file, result, options)
return result
}
await this.runHook('bajo:beforeReadConfig', file, options)
parserOpts.readFromFile = true
if (!ns) ns = this.ns
file = resolvePath(this.getPluginFile(file))
let ext = path.extname(file)
const fname = path.dirname(file) + '/' + path.basename(file, ext)
ext = ext.toLowerCase()
if (ext === '.js') {
const { readHandler } = find(this.app.configHandlers, { ext })
return await output(await readHandler.call(this.app[ns], file, parserOpts))
}
if (ext === '.json') return await output(await this.fromJson(file, parserOpts))
if (!['', '.*'].includes(ext)) {
const item = find(this.app.configHandlers, { ext })
if (!item) {
if (!ignoreError) throw this.error('cantParse%s', file, { code: 'BAJO_CONFIG_NO_PARSER' })
return await output(defValue)
}
return await output(await item.readHandler.call(this.app[ns], file, parserOpts))
}
const item = pattern ?? `${fname}.{${map(map(this.app.configHandlers, 'ext'), k => k.slice(1)).join(',')}}`
const files = await fastGlob(item, globOpts ?? {})
if (files.length === 0) {
if (!ignoreError) throw this.error('noConfigFileFound', { code: 'BAJO_CONFIG_FILE_NOT_FOUND' })
return await output(defValue)
}
let config = defValue
for (const f of files) {
const ext = path.extname(f).toLowerCase()
const item = find(this.app.configHandlers, { ext })
if (!item) {
if (!ignoreError) throw this.error('cantParse%s', f, { code: 'BAJO_CONFIG_NO_PARSER' })
continue
}
config = await item.readHandler.call(this.app[ns], f, parserOpts)
if (!isEmpty(config)) break
}
return await output(config)
}
/**
* Read and parse json file
*
* @method
* @param {string} file - File to read
* @param {boolean} [thrownNotFound=false] - If ```true```, silently ignore if file is not found
* @returns {Object}
*/
readJson = (file, thrownNotFound = false) => {
const { parseObject } = this.app.lib
if (isPlainObject(thrownNotFound)) thrownNotFound = false
if (!fs.existsSync(file) && thrownNotFound) throw this.error('notFound%s%s', this.t('file'), file)
let resp
try {
resp = fs.readFileSync(file, 'utf8')
} catch (err) {}
if (isEmpty(resp)) return resp
return parseObject(JSON.parse(resp))
}
async fromJs (file, options = {}) {
const args = options.args ?? []
let mod = await importModule(file)
if (isFunction(mod)) mod = await mod.call(this, ...args)
return mod
}
fromJson (data, options = {}) {
const content = options.readFromFile ? fs.readFileSync(data, 'utf8') : data
return JSON.parse(content)
}
toJson = (data, options = {}) => {
const content = JSON.stringify(data, null, omit(options, ['writeToFile']))
if (options.writeToFile) {
fs.writeFileSync(options.saveAsFile, content, 'utf8')
return
}
return content
}
/**
* Read all config files by path
*
* @method
* @async
* @param {string} path - Base path to start looking config files
* @returns {Object}
*/
readAllConfigs = async (path) => {
const { defaultsDeep } = this.app.lib.aneka
let cfg = {}
let ext = {}
// default config file
try {
cfg = await this.readConfig(`${path}.*`)
} catch (err) {
if (['BAJO_CONFIG_NO_PARSER'].includes(err.code)) throw err
}
// env based config file
try {
ext = await this.readConfig(`${path}-${this.config.env}.*`)
} catch (err) {
if (!['BAJO_CONFIG_FILE_NOT_FOUND'].includes(err.code)) throw err
}
return defaultsDeep({}, ext, cfg)
}
/**
* Run named hook/event
*
* @method
* @async
* @param {TNsPathPairs} hookName
* @param {...any} [args] - Argument passed to the hook function
* @returns {Array} Array of hook execution results
*/
runHook = async (hookName, ...args) => {
let ns
let path
let subNs
try {
({ ns, subNs, path } = this.breakNsPath(hookName ?? ''))
} catch (err) {
return
}
let fns = filter(this.hooks, { ns, subNs, path })
if (isEmpty(fns)) return []
fns = orderBy(fns, ['level'])
const results = []
for (const i in fns) {
const fn = fns[i]
const scope = this.app[fn.src ?? 'main'] ?? this
if (fn.noWait) fn.handler.call(scope, ...args)
else {
const res = await fn.handler.call(scope, ...args)
results.push({
hook: hookName,
resp: res
})
}
}
return results
}
/**
* Save item as file in Bajo's download directory. That is a directory inside your
* Bajo plugin's data directory.
*
* If file exists already, file will automatically be
* renamed incrementally.
*
* @method
* @async
* @param {string} file - File name
* @param {Object} item - Item to save
* @param {boolean} [printSaved=true] - Print info on screen
* @returns {string} Full file path
*/
saveAsDownload = async (file, item, printSaved = true) => {
const { print, getPluginDataDir } = this.app.bajo
const fname = increment(`${getPluginDataDir(this.ns)}/download/${trim(file, '/')}`, { fs: true })
const dir = path.dirname(fname)
if (!fs.existsSync(dir)) fs.ensureDirSync(dir)
await fs.writeFile(fname, item, 'utf8')
if (printSaved) print.succeed('savedAs%s', path.resolve(fname), { skipSilence: true })
return fname
}
}
export default Bajo