@boost/config
Version:
Powerful convention based finder, loader, and manager of both configuration and ignore files.
378 lines (317 loc) • 11.3 kB
text/typescript
/* eslint-disable no-param-reassign, no-await-in-loop */
import { minimatch } from 'minimatch';
import {
isFilePath,
isModuleName,
type PackageStructure,
Path,
PathResolver,
toArray,
} from '@boost/common';
import type { Blueprint, Schemas } from '@boost/common/optimal';
import { color } from '@boost/internal';
import { ConfigError } from './ConfigError';
import {
DEFAULT_EXTS,
PACKAGE_FILE,
ROOT_CONFIG_DIR_REGEX,
ROOT_CONFIG_FILE_REGEX,
} from './constants';
import { Finder } from './Finder';
import { createFileName } from './helpers/createFileName';
import { getEnv } from './helpers/getEnv';
import { loadCjs } from './loaders/cjs';
import { loadJs } from './loaders/js';
import { loadJson } from './loaders/json';
import { loadMjs } from './loaders/mjs';
import { loadTs } from './loaders/ts';
import { loadYaml } from './loaders/yaml';
import type {
ConfigFile,
ConfigFinderOptions,
ExtType,
FileSource,
FileType,
OverridesSetting,
} from './types';
export class ConfigFinder<T extends object> extends Finder<ConfigFile<T>, ConfigFinderOptions<T>> {
blueprint(schemas: Schemas): Blueprint<ConfigFinderOptions<T>> {
const { array, bool, func, shape, string } = schemas;
return {
errorIfNoRootFound: bool(true),
extendsSetting: string(),
extensions: array(DEFAULT_EXTS).of(string<ExtType>()),
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: Path): Promise<PackageStructure> {
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<PackageStructure>(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: Path): Promise<Path[]> {
const { extensions, includeEnv } = this.options;
const isRoot = this.isRootDir(dir);
const hash = isRoot ? '#root' : '#branch';
return this.cache.cacheFilesInDir(dir, hash, async () => {
const paths: Path[] = [];
for (const ext of extensions) {
const files: (Path | false)[] = [];
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 as Path).exists()) {
paths.push(configPath as Path);
}
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: FileType, ext: string, isEnv: boolean): string {
const { name } = this.options;
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: Path, foundFiles: Path[]): Promise<ConfigFile<T>[]> {
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.
*/
protected async extractExtendedConfigs(configs: ConfigFile<T>[]): Promise<ConfigFile<T>[]> {
const { name, extendsSetting, resolver } = this.options;
const extendsPaths: Path[] = [];
this.debug('Extracting configs to extend from');
for (const { config, path, source } of configs) {
const key = extendsSetting as keyof typeof config;
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) as unknown as string[]).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.
*/
protected extractOverriddenConfigs(basePath: Path, configs: ConfigFile<T>[]): ConfigFile<T>[] {
const { overridesSetting } = this.options;
const overriddenConfigs: ConfigFile<T>[] = [];
this.debug(
'Extracting configs to override with (matching against %s)',
color.filePath(basePath.path()),
);
configs.forEach(({ config, path, source }) => {
const key = overridesSetting as keyof typeof config;
const overrides = config[key];
if (source === 'root') {
delete config[key];
} else if (overrides) {
throw new ConfigError('ROOT_ONLY_OVERRIDES', [key]);
} else {
return;
}
(toArray(overrides) as unknown as OverridesSetting<T>).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.
*/
protected async loadConfig(path: Path, source?: FileSource): Promise<ConfigFile<T>> {
const pkg = await this.determinePackageScope(path);
const config = await this.cache.cacheFileContents(path, async () => {
const { loaders } = this.options;
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'),
};
}
}