'use strict'
import fs from 'fs'
import path from 'path'
import { print, debug, warn, error } from './lib/ConsoleUtils'
import { toCamelCase, toSnakeCase, toKebabCase } from '@beautiful-code/string-utils'
import { isBoolean, isFunction, isObject, isString } from '@beautiful-code/type-utils'
import { DuplicateKeyError, InvalidArgumentsError } from './errors'
/*
* TODO: Export these functions to external modules.
*/
const Externals = {
filterObject: (object) => {
const result = {}
Object.keys(object)
.filter((key) => object[key] !== undefined)
.forEach((key) => result[key] = object[key])
return result
},
hasDuplicateKeys: (object, other) => {
let result = false
Object.keys(object).map((key) => {
if(other.hasOwnProperty(key)){
result = true
return false
}
})
return result
},
hasKeyAvailable: (obj, key, onSuccess, onError) => {
let contains = obj.hasOwnProperty(key)
try {
if(contains){
throw new DuplicateKeyError(`The given object has duplicate keys '${key}'. Make sure all directories have unique key, or use scopes / aliases'`)
} else {
isFunction(onSuccess) ? onSuccess(true) : undefined
return true
}
} catch (e){
isFunction(onError) ? onError(false, e) : undefined
return false
}
return false
},
isFileURI: (uri) => {
return uri.split('/').pop().indexOf('.') > -1
},
isDirectoryURI: (uri) => {
return path.split('/').pop().indexOf('.') == -1
}
}
let { filterObject, hasDuplicateKeys, hasKeyAvailable, isDirectoryURI, isFileURI } = Externals
/**
* Class that creates a suite of methods cna collections useful for resolving paths within a project. Includes a directory
* resolver, which creates relative path resolvers for each directory in a given directory map. Support for resolver
* renaming and alias generation, which formats the resolver name to reflect the alias as the root rather than the full path.
* Aliases are suitable for use in webpack and similar libraries.
*
* @author Chris Coppola <mynamereallysux@gmail.com>
*/
class PathResolver {
/**
* @property {String} aliasRoot Namespace, or property name, that should contain the alias map.
* @property {Number} depth How many directory levels should be processed. Set to -1 for inifinite depth.
* @property {Boolean} duplicateAliases Should both the alias and non alias resolvers be used when an alias is present.
* @property {String} namespace Namespace, or property name, that should contain the directory resolver.
* @property {Object} paths Object mapping projects directory structure.
* @property {String} resolverPrefix Unvalidated options object.
* @property {String} rootPath Root path all resolver keys and aliases will be relative to.
*
* @static
* @const
* @memberof PathResolver
*/
static defaultOptions = {
aliasRoot: 'aliases',
depth: -1,
duplicateAliases: false,
fileRoot: 'files',
namespace: 'paths',
paths: {},
resolverPrefix: 'resolve',
rootPath: fs.realpathSync(process.cwd()),
}
/**
* Each directory can contain a config option. These options are defined by an '_' property within the directory map.
*
* @example
* let map = new PathResolver({
* _: { alias: '@root' } // resolveRoot() -> path/to/project
* src: { _: { name: source } }, // resolveSource() -> path/to/project/src
* dist: { _: { ignore: true }, // this route is ignored, children still render
* css: {} // resolveDistCss() -> path/to/project/src/css
* },
* libs: { _: { ignoreBranch: true } // *this and child routes ignored
* ...
* }
* })
*
* @property {String} name Rename current directory's resolver function, affecting children as well.
* @property {String} alias Rename current directory's resolver function and set as the root scope, affecting children as well.
* @property {Boolean} ignore Does not export the current directory as a resolver or as an alias.
* @property {Boolean} duplicateAliases Does not export the current directory or it's children as a resolver or alias.
*
* @static
* @const
* @memberof PathResolver
*/
static defaultConfig = {
name: undefined,
alias: undefined,
ignore: false,
ignoreBranch: false
}
// Class Initialization
/**
* Creates an instance of PathResolver. Passes arguments to {@link PathResolver#initialize} to be processed.
*
* @param {!String|!Object} rootPath|paths Projects root path or Object mapping projects directory structure.
* @param {?Object} paths|options Object mapping projects directory structure or options object.
* @param {?Object} options Options object.
*
* @see {@link PathResolver#initialize}
*/
constructor(...args){
this.initialize(...args)
}
getDirectoryResolver = () => {
let { namespace } = this.options
return this[namespace]
}
getAliasMap = () => {
let { aliasRoot } = this.options
return this[aliasRoot]
}
/**
* Configures an instance of path resolver. Can be used after a {@link PathResolver} instance is
* created to defer the configuration. Passes arguments to the {@link PathResolver#initialize} function to be
* processed and validated and sets the options instance property. Then passes on the configured
* rootPath, paths object, and designated fileRoot
*
* @param {!String|!Object} rootPath|paths Projects root path or Object mapping projects directory structure.
* @param {?Object} paths|options Object mapping projects directory structure or options object.
* @param {?Object} options Options object.
*
* @function
* @public
*/
initialize = (...args) => {
this.options = this._handleArgs(...args)
let { directoryResolver, aliasMap } = this._getInitialResolvers()
let fullResolver = this._createResolver(directoryResolver, aliasMap)
}
/**
* Creates a resolver function, relative to the given path.
*
* @param {!String} rootPath Absolute path which the resolver will be relative to.
* @returns {Function} Returns a function that returns a path relative to rootPath.
*
* @example
* let resolver = makeRelativeResolver('absolute/path')
* let path = resolver('index.html')
*
* console.log(path) // absolute/path/index.html
* @function
* @public
*/
makeRelativeResolver = (rootPath) => (relativePath = '') => path.resolve(rootPath, relativePath)
/**
* Gets a string representation of the resolver incuding a list of all resolve functions and their
* resolved paths, as well as a list of aliases and their resolved paths.
*
* @returns {Function} Returns a string representation of the resolver..
*
* @function
* @public
*/
toString = () => {
let directoryResolver = this.getDirectoryResolver()
let aliasMap = this.getAliasMap()
const PADDING_SIZE = 24
return `======================================
:: DIRECTORY RESOLVER ::
${'function'.padEnd(PADDING_SIZE)}\t ${'result'}
${'-----------------'.padEnd(PADDING_SIZE)}\t ------------------
${Object.entries(directoryResolver).reduce((str, [name, resolver], index, collection) => {
let formatted = name.padEnd(PADDING_SIZE)
let linebreak = index < collection.length - 1 ? '\n' : ''
str += `${formatted} \t ${resolver()}${linebreak}`
return str
}, '')}
======================================
:: ALIAS MAP ::
${'name'.padEnd(PADDING_SIZE)}\t ${'value'}
${'-----------------'.padEnd(PADDING_SIZE)}\t ------------------
${Object.entries(aliasMap).reduce((str, [alias, path], index, collection) => {
let formatted = alias.padEnd(PADDING_SIZE)
let linebreak = index < collection.length - 1 ? '\n' : ''
str += `${formatted} \t ${path}${linebreak}`
return str
}, '')}`
}
/**
* Prints out a string representation of the resolver, the result of the {@link PathResolver#toString toString} method.
* Includes a list of all resolve functions and their resolved paths, as well as a list of aliases and their resolved paths.
*
* @returns {Function} Returns a string representation of the resolver..
*
* @see {@link PathResolver#toString toString}
* @function
* @public
*/
printDetails = () => print(this.toString())
// Private Methods
_addAlias = (key, path, resolver) => {
if(hasKeyAvailable(resolver, key)) resolver[key] = this.makeRelativeResolver(path)()
}
/**
* Creates and adds relative resolver function to the passed in resolver object, using the key as the object key.
*
* @param {!String} key Name of the resolver function to be generated.
* @param {!String} path Absolute path which the resolver will be relative to.
* @param {!String} resolver Object that will contain the function.
*
* @function
* @private
* @memberof PathResolver#
*/
_addResolver = (key, path, resolver) => {
if(hasKeyAvailable(resolver, key)) resolver[key] = this.makeRelativeResolver(path)
}
/**
* Core function that creates both the directory resolver and the alias resolver and returns a combined resolver object.
*
* @param {!Object} directoryResolver Object that will contain relative resolver functions.
* @param {!Object} aliasMap Object that will contain resolved aliases.
* @param {!Object} options The object that will contain the function.
* @param {!Number} options.depth How many directory levels should be processed. Set to -1 for inifinite depth.
* @param {!String} options.paths Object mapping projects directory structure or options object.
* @param {!String} options.rootPath Projects root path or Object mapping projects directory structure.
* @returns {Object} Returns an object containing a directory resolver and a list of aliases
*
* @function
* @private
* @memberof PathResolver#
*/
_createResolver = (directoryResolver, aliasMap) => {
let { depth, duplicateAliases, paths, rootPath } = this.options
const _handleRoot = () => {
let localRootPath = this._formatPath(rootPath)
let rootConfig = this._getConfig(paths) || {}
let { alias, name } = rootConfig
let localKey = name || ''
let resolverKey = this._getDirectoryResolverKey(localKey, '')
let aliasUsed = this._handleAlias(localKey, alias, localRootPath, directoryResolver, aliasMap)
let aliasIsNotUsed = duplicateAliases || !aliasUsed
if(aliasIsNotUsed){
this._addResolver(resolverKey, localRootPath, directoryResolver)
}
}
const _resolveLevel = ({
localPaths,
parentPath,
scope,
index = 0,
ignoreDuplicates = false}) => {
const _handleStringValue = (value) => {
return this._formatPath(value, parentPath, rootPath)
}
const _handleObjectValue = (key, value) => {
let isConfig = key == '_'
if (!isConfig){
let localRootPath = this._formatPath(key, parentPath)
let { alias, name, ignore, ignoreBranch } = this._getConfig(value) || {}
if(ignoreBranch) return
let aliasUsed = this._handleAlias(key, alias, localRootPath, directoryResolver, aliasMap)
let localKey = name ? name : key
let nextScope = this._formatScope(localKey, scope)
let resolverKey = this._getDirectoryResolverKey(localKey, scope)
let isValidDepth = depth == -1 || index <= depth
let duplicatesAreNotPresent = !directoryResolver.hasOwnProperty(resolverKey)
let aliasIsNotUsed = duplicateAliases || !aliasUsed
if(!ignore){
if(isValidDepth && duplicatesAreNotPresent && aliasIsNotUsed){
this._addResolver(resolverKey, localRootPath, directoryResolver)
}
}
return _resolveLevel({
localPaths: value,
parentPath: localRootPath,
scope: nextScope,
index: ++index,
ignoreDuplicates: true
})
}
}
const localResolver = Object.entries(localPaths).reduce((resolver, [key, value]) => {
if(isString(value) && isFileURI(value) && key != '_'){
resolver[key] = _handleStringValue(value)
} else if(isObject(value)){
resolver[key] = _handleObjectValue(key, value)
}
return resolver
}, {})
return localResolver
}
_handleRoot()
return _resolveLevel({localPaths: paths})
}
/**
* Format path which is a concatenation of name, an optional parent path, and an optional root path.
*
* @param {!Object} name Name of the current directory or file.
* @param {Object} parentPath Relative path of containing directories.
* @param {Object} rootPath Absolute path which the resolved path will be relative to.
* @returns {String} Resolved path.
*
* @function
* @private
* @memberof PathResolver#
*/
_formatPath = (name, parentPath, rootPath) => {
let formatted
if(parentPath && rootPath){
formatted = path.resolve(rootPath, parentPath, name)
} else {
if(parentPath || rootPath){
formatted = path.resolve(parentPath || rootPath , name)
} else {
formatted = name
}
}
return formatted
}
/**
* Format resolver key, name of dynamically generated function.
*
* @param {!Object} name Name of the current directory or file.
* @returns {String} Resolver key, concatenation of 'resolverPrefix' option and passed in name.
*
* @function
* @private
* @memberof PathResolver#
*/
_formatResolverKey = (name) =>
toCamelCase(`${this.options.resolverPrefix}-${toSnakeCase(name.replace(/[\/\\]/, '-'))}`)
/**
* Format scope which is a concatenation of a key and the previous scope in kebab case.
*
* @param {!Object} name Name of the current directory or file.
* @param {Object} scope String representation of the nested path in kebab case.
* @returns {String} Formatted scope in kebab case.
*
* @function
* @private
* @memberof PathResolver#
*/
_formatScope = (name, scope) => scope ? `${scope}-${name}` : name
/**
* Gets a directory's config object.
*
* @param {!Object} paths Object mapping projects directory structure.
*
* @returns {Object} Configuration object for current directory.
*
* @function
* @private
* @memberof PathResolver#
*/
_getConfig = (paths) => paths._
/**
* Creates a directory resolver key, or a dynamic function name, to be applied to the directory resolver.
*
* @param {?String} name Name of current directory or file.
* @param {?String} scope String representation of the nested path in kebab case.
*
* @returns {String} Name for dynamically generated resolver function.
*
* @private
* @function
* @memberof PathResolver#
*/
_getDirectoryResolverKey = (name = '', scope) => {
let keyToResolve = scope && name ? `${scope}-${name}` : name
return this._formatResolverKey(keyToResolve)
}
/**
* Initializes directory resolver, alias map, and file map and determines location. Default location for directory resolver is
* the {@link PathResolver} instance. Default location for the alias resolver is the 'aliases' property.
*
* @param {?String} key Name of current directory or file.
* @param {?String} scope String representation of the nested path in kebab case.
*
* @returns {String} Name for dynamically generated resolver function.
*
* @private
* @function
* @memberof PathResolver#
*/
_getInitialResolvers = () => {
let { aliasRoot, fileRoot, namespace } = this.options
let directoryResolver, aliasMap
if(namespace){
this[namespace] = {}
directoryResolver = this[namespace]
} else {
warn(`PathResolver's 'namespace' property was invalid.`)
}
if(aliasRoot){
this[aliasRoot] = {}
aliasMap = this[aliasRoot]
} else {
warn(`PathResolver's 'aliasRoot' property was invalid.`)
}
return {
directoryResolver,
aliasMap,
}
}
_handleAlias = (key, alias, path, directoryResolver, aliasMap) => {
let aliasUsed = false
if(alias){
let aliasKey = alias.replace(/@/g, '')
let aliasScope = toKebabCase(aliasKey)
let aliasMapKey = this._getDirectoryResolverKey(aliasKey)
if(hasKeyAvailable(aliasMap, aliasMapKey)){
this._addResolver(aliasMapKey, path, directoryResolver)
this._addAlias(alias, path, aliasMap)
aliasUsed = true
}
}
return aliasUsed
}
/**
* Handles initial arguments and converts them into an objects file. Validates option properties using {@link PathResolver#_validateOptions}.
*
* @param {!String|!Object} rootPath|paths Projects root path or Object mapping projects directory structure.
* @param {?Object} paths|options Object mapping projects directory structure or options object.
* @param {?Object} options Options object.
*
* @returns {Object} Formatted and validated options object.
* @see {@link PathResolver#_validateOptions}
*
* @function
* @private
* @memberof PathResolver#
*/
_handleArgs = (...args) => {
let options
switch(args.length){
case 0: {
warn(`A 'PathResolver' object was passed no initial arguments. Initialization must be done manually by running the 'initialize' function.`)
options = {}
} break
case 1: {
if(isObject(args[0])){
options = {
paths: args[0]
}
}
} break
case 2: {
if(isObject(args[0]) && isObject(args[1])){
if(args[1].hasOwnProperty('paths')){
warn(`PathResolver was passed a 'paths' argument and an options object with the 'paths' property. Will use 'paths' argument.`)
}
options = {
...args[1],
paths: args[0]
}
}
if(isString(args[0]) && isObject(args[1])){
options = {
rootPath: args[0],
paths: args[1]
}
}
} break
case 3: {
if(args[2].hasOwnProperty('rootPath')){
warn(`PathResolver was passed a 'rootPath' argument and an options object with the 'rootPath' property. Will use 'rootPath' argument.`)
}
if(args[2].hasOwnProperty('paths')){
warn(`PathResolver was passed a 'paths' argument and an options object with the 'paths' property. Will use 'paths' argument.`)
}
options = {
...args[2],
rootPath: args[0],
paths: args[1]
}
} break
default: {
throw new InvalidArgumentsError(`PathResolver accepts between 1-3 arguments, '${args.length}' found.`)
}
}
return this._validateOptions(options)
}
/**
* @deprecated since 0.1.1
*
* Determines if directory object map contains an alias field.
*
* @param {!Object} paths Object mapping projects directory structure.
*
* @returns {Boolean} True if contains alias.
*
* @function
* @private
* @memberof PathResolver#
*/
_hasAlias = (paths) => paths.hasOwnProperty('_') && paths._.alias
/**
* Determines if directory object map contains a config property, indicated by a key of underscore.
*
* @param {!Object} paths Object mapping projects directory structure.
*
* @returns {Boolean} True if contains config property.
*
* @function
* @private
* @memberof PathResolver#
*/
_hasConfig = (paths) => paths.hasOwnProperty('_')
/**
* @deprecated since 0.1.0
*
* Determines if directory object map contains a local alias field.
*
* @param {!Object} paths Object mapping projects directory structure.
*
* @returns {Boolean} True if contains local alias.
*
* @function
* @private
* @memberof PathResolver#
*/
_hasName = (paths) => paths.hasOwnProperty('_') && paths._.name
/**
* Validates options object and set defaults.
*
* @param {!Object} options Unvalidated options object.
*
* @returns {Boolean} Validated options object.
*
* @function
* @private
* @memberof PathResolver#
*/
_validateOptions = (options) => {
let { duplicateAliases, namespace, rootPath, resolverPrefix, ...other } = options
let validated = Object.assign({}, PathResolver.defaultOptions, filterObject({
...other,
duplicateAliases: isBoolean(duplicateAliases) ? duplicateAliases : undefined,
namespace: isString(namespace) && namespace.trim().length > 0 ? namespace : undefined,
rootPath: isString(rootPath) && rootPath.trim().length > 0 ? rootPath : undefined,
resolverPrefix: isString(rootPath) && rootPath.trim().length > 0 ? rootPath : undefined
}))
return validated
}
}
export default PathResolver
export { PathResolver }