UNPKG

@boost/config

Version:

Powerful convention based finder, loader, and manager of both configuration and ignore files.

302 lines (284 loc) 11.4 kB
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