@boost/config
Version:
Powerful convention based finder, loader, and manager of both configuration and ignore files.
302 lines (284 loc) • 11.4 kB
JavaScript
import { minimatch } from 'minimatch';
import { PathResolver, toArray, isModuleName, Path, isFilePath } from '@boost/common';
import { color } from '@boost/internal';
import { ConfigError } from './ConfigError.mjs';
import { DEFAULT_EXTS, PACKAGE_FILE, ROOT_CONFIG_DIR_REGEX, ROOT_CONFIG_FILE_REGEX } from './constants.mjs';
import { Finder } from './Finder.mjs';
import { createFileName } from './helpers/createFileName.mjs';
import { getEnv } from './helpers/getEnv.mjs';
import { loadCjs } from './loaders/cjs.mjs';
import { loadJs } from './loaders/js.mjs';
import { loadJson } from './loaders/json.mjs';
import { loadMjs } from './loaders/mjs.mjs';
import { loadTs } from './loaders/ts.mjs';
import { loadYaml } from './loaders/yaml.mjs';
/* eslint-disable no-param-reassign, no-await-in-loop */
class ConfigFinder extends Finder {
blueprint(schemas) {
const array = schemas.array,
bool = schemas.bool,
func = schemas.func,
shape = schemas.shape,
string = schemas.string;
return {
errorIfNoRootFound: bool(true),
extendsSetting: string(),
extensions: array(DEFAULT_EXTS).of(string()),
includeEnv: bool(true),
loaders: shape({
cjs: func(() => loadCjs).notNullable(),
js: func(() => loadJs).notNullable(),
json: func(() => loadJson).notNullable(),
json5: func(() => loadJson).notNullable(),
mjs: func(() => loadMjs).notNullable(),
ts: func(() => loadTs).notNullable(),
yaml: func(() => loadYaml).notNullable(),
yml: func(() => loadYaml).notNullable()
}).exact(),
name: string().required().camelCase(),
overridesSetting: string(),
resolver: func(() => PathResolver.defaultResolver).notNullable()
};
}
/**
* Determine a files package scope by finding the first parent `package.json`
* when traversing up directories. We will leverage the cache as much as
* possible for performance.
*
* @see https://nodejs.org/api/esm.html#esm_package_scope_and_file_extensions
*/
async determinePackageScope(dir) {
let currentDir = dir.isDirectory() ? dir : dir.parent();
this.debug('Determining package scope for %s', color.filePath(dir.path()));
while (!this.isFileSystemRoot(currentDir)) {
const pkgPath = currentDir.append(PACKAGE_FILE);
const cache = this.cache.getFileCache(pkgPath);
if (cache) {
if (cache.exists) {
this.debug('Scope found at %s', color.filePath(pkgPath.path()));
return cache.content;
}
// Fall-through
} else if (pkgPath.exists()) {
this.debug('Scope found at %s', color.filePath(pkgPath.path()));
return this.cache.cacheFileContents(pkgPath, () => loadJson(pkgPath));
} else {
this.cache.markMissingFile(pkgPath);
}
currentDir = currentDir.parent();
}
throw new ConfigError('PACKAGE_UNKNOWN_SCOPE');
}
/**
* Find all configuration and environment specific files in a directory
* by looping through all the defined extension options.
* Will only search until the first file is found, and will not return multiple extensions.
*/
async findFilesInDir(dir) {
const _this$options = this.options,
extensions = _this$options.extensions,
includeEnv = _this$options.includeEnv;
const isRoot = this.isRootDir(dir);
const hash = isRoot ? '#root' : '#branch';
return this.cache.cacheFilesInDir(dir, hash, async () => {
const paths = [];
for (const ext of extensions) {
const files = [];
if (isRoot) {
files.push(dir.append(this.getFileName('root-file', ext, false)), includeEnv && dir.append(this.getFileName('root-file', ext, true)), dir.append(this.getFileName('root-folder', ext, false)), includeEnv && dir.append(this.getFileName('root-folder', ext, true)));
} else {
files.push(dir.append(this.getFileName('branch', ext, false)), includeEnv && dir.append(this.getFileName('branch', ext, true)));
}
await Promise.all(files.filter(Boolean).map(configPath => {
if (configPath.exists()) {
paths.push(configPath);
}
return configPath;
}));
// Once we find any file, we abort looking for others
if (paths.length > 0) {
break;
}
}
this.debug.invariant(paths.length > 0, `Finding config files in ${color.filePath(dir.path())}`, paths.map(path => path.name()).join(', '), 'No files');
// Make sure env takes higher precedence
paths.sort((a, b) => a.path().length - b.path().length);
return paths;
});
}
/**
* Create and return a config file name, with optional branch and environment variants.
*/
getFileName(type, ext, isEnv) {
const name = this.options.name;
return createFileName(type, name, ext, isEnv ? getEnv(name) : '');
}
/**
* Load file and package contents from a list of file paths.
* Extract and apply extended and override configs based on the base path.
*/
async resolveFiles(basePath, foundFiles) {
this.debug('Resolving %d config files', foundFiles.length);
const configs = await Promise.all(foundFiles.map(filePath => this.loadConfig(filePath)));
// Overrides take the highest precedence and must appear after everything,
// including branch level configs. However, they must extract first so that
// extends functionality can be inherited (below).
if (this.options.overridesSetting) {
const overriddenConfigs = await this.extractOverriddenConfigs(basePath, configs);
this.debug('Overriding %d configs', overriddenConfigs.length);
if (overriddenConfigs.length > 0) {
configs.push(...overriddenConfigs);
}
}
// Configs that have been extended from root configs must
// appear before everything else, in the order they were defined
if (this.options.extendsSetting) {
const extendedConfigs = await this.extractExtendedConfigs(configs);
this.debug('Extending %d configs', extendedConfigs.length);
if (extendedConfigs.length > 0) {
configs.unshift(...extendedConfigs);
}
}
return configs;
}
/**
* Extract a list of config files to extend, in order, from the list of previously loaded
* config files, which is typically from the root. The list to extract can be located within
* a property that matches the `extendsSetting` option.
*/
async extractExtendedConfigs(configs) {
const _this$options2 = this.options,
name = _this$options2.name,
extendsSetting = _this$options2.extendsSetting,
resolver = _this$options2.resolver;
const extendsPaths = [];
this.debug('Extracting configs to extend from');
for (const _ref of configs) {
const config = _ref.config;
const path = _ref.path;
const source = _ref.source;
const key = extendsSetting;
const extendsFrom = config[key];
if (source === 'root' || source === 'overridden') {
delete config[key];
} else if (extendsFrom) {
throw new ConfigError('EXTENDS_ONLY_ROOT', [key]);
} else {
// eslint-disable-next-line no-continue
continue;
}
const extendedPaths = await Promise.all(toArray(extendsFrom).map(async extendsPath => {
// Node module
if (isModuleName(extendsPath)) {
const modulePath = new Path(extendsPath, createFileName('preset', name, 'js', 'preset'));
this.debug('Extending config from node module: %s', color.moduleName(modulePath.path()));
return new Path(await resolver(modulePath.path()));
}
// File path
if (isFilePath(extendsPath)) {
let filePath = new Path(extendsPath);
// Relative to the config file its defined in
if (!filePath.isAbsolute()) {
filePath = path.parent().append(extendsPath);
}
this.debug('Extending config from file path: %s', color.filePath(filePath.path()));
return filePath;
}
// Unknown
throw new ConfigError('EXTENDS_UNKNOWN_PATH', [extendsPath]);
}));
extendsPaths.push(...extendedPaths);
}
return Promise.all(extendsPaths.map(path => this.loadConfig(path, 'extended')));
}
/**
* Extract all root config overrides that match the current path used to load with.
* Overrides are located within a property that matches the `overridesSetting` option.
*/
extractOverriddenConfigs(basePath, configs) {
const overridesSetting = this.options.overridesSetting;
const overriddenConfigs = [];
this.debug('Extracting configs to override with (matching against %s)', color.filePath(basePath.path()));
configs.forEach(({
config,
path,
source
}) => {
const key = overridesSetting;
const overrides = config[key];
if (source === 'root') {
delete config[key];
} else if (overrides) {
throw new ConfigError('ROOT_ONLY_OVERRIDES', [key]);
} else {
return;
}
toArray(overrides).forEach(({
exclude,
include,
settings
}) => {
const options = {
dot: true,
matchBase: true
};
const excludePatterns = toArray(exclude);
const excluded = excludePatterns.some(pattern => minimatch(basePath.path(), pattern, options));
const includePatterns = toArray(include);
const included = includePatterns.some(pattern => minimatch(basePath.path(), pattern, options));
const passes = included && !excluded;
this.debug.invariant(passes, `Matching with includes "${includePatterns}" and excludes "${excludePatterns}"`, 'Matched',
// eslint-disable-next-line no-nested-ternary
excluded ? 'Excluded' : included ? 'Not matched' : 'Not included');
if (passes) {
overriddenConfigs.push({
config: settings,
path,
source: 'overridden'
});
}
});
});
return overriddenConfigs;
}
/**
* Load config contents from the provided file path using one of the defined loaders.
*/
async loadConfig(path, source) {
const pkg = await this.determinePackageScope(path);
const config = await this.cache.cacheFileContents(path, async () => {
const loaders = this.options.loaders;
const ext = path.ext(true);
this.debug('Loading config %s with type %s', color.filePath(path.path()), color.symbol(ext));
switch (ext) {
case 'cjs':
return loaders.cjs(path, pkg);
case 'js':
return loaders.js(path, pkg);
case 'json':
case 'json5':
return loaders.json(path, pkg);
case 'mjs':
// Not easily testable yet
// istanbul ignore next
return loaders.mjs(path, pkg);
case 'ts':
case 'tsx':
return loaders.ts(path, pkg);
case 'yaml':
case 'yml':
return loaders.yaml(path, pkg);
default:
throw new ConfigError('LOADER_UNSUPPORTED', [ext]);
}
});
const isRoot = ROOT_CONFIG_DIR_REGEX.test(path.path()) || ROOT_CONFIG_FILE_REGEX.test(path.path());
return {
config,
path,
source: source ?? (isRoot ? 'root' : 'branch')
};
}
}
export { ConfigFinder };
//# sourceMappingURL=ConfigFinder.mjs.map