UNPKG

bajo

Version:

The ultimate framework for whipping up massive apps in no time

1,084 lines (1,031 loc) 41.6 kB
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