@boost/config
Version:
Powerful convention based finder, loader, and manager of both configuration and ignore files.
221 lines (181 loc) • 5.86 kB
text/typescript
import { Contract, type ModuleResolver, Path, type PortablePath } from '@boost/common';
import { type Blueprint, schemas } from '@boost/common/optimal';
import { Event, WaterfallEvent } from '@boost/event';
import { Cache } from './Cache';
import { ConfigFinder } from './ConfigFinder';
import { IgnoreFinder } from './IgnoreFinder';
import { Processor } from './Processor';
import type {
ConfigFile,
ConfigFinderOptions,
Handler,
IgnoreFile,
ProcessedConfig,
ProcessorOptions,
} from './types';
export abstract class Configuration<T extends object> extends Contract<T> {
/**
* Called after config files are loaded but before processed. Can modify config file list.
* @category Events
*/
readonly onLoadedConfig = new WaterfallEvent<ConfigFile<T>[]>('loaded-config');
/**
* Called after ignore files are loaded. Can modify ignore file list.
* @category Events
*/
readonly onLoadedIgnore = new WaterfallEvent<IgnoreFile[]>('loaded-ignore');
/**
* Called after config files are loaded and processed.
* @category Events
*/
readonly onProcessedConfig = new Event<[Required<T>]>('processed-config');
private cache: Cache;
private configFinder: ConfigFinder<T>;
private ignoreFinder: IgnoreFinder;
private processor: Processor<T>;
constructor(name: string, resolver?: ModuleResolver) {
super();
this.cache = new Cache();
this.configFinder = new ConfigFinder({ name, resolver }, this.cache);
this.ignoreFinder = new IgnoreFinder({ name }, this.cache);
this.processor = new Processor({ name });
this.bootstrap();
}
/**
* Clear all cache.
*/
clearCache(): this {
this.clearFileCache();
this.clearFinderCache();
return this;
}
/**
* Clear all cached file contents.
*/
clearFileCache(): this {
this.cache.clearFileCache();
return this;
}
/**
* Clear all cached directory and file path information.
*/
clearFinderCache(): this {
this.cache.clearFinderCache();
return this;
}
/**
* Attempt to find the root directory starting from the provided directory.
* Once the root is found, it will be cached for further lookups,
* otherwise an error is thrown based on current configuration.
*/
async findRootDir(fromDir: PortablePath = process.cwd()): Promise<Path> {
return this.getConfigFinder().findRootDir(fromDir);
}
/**
* Traverse upwards from the branch directory, until the root directory is found,
* or we reach to top of the file system. While traversing, find all config files
* within each branch directory, and the root.
*/
async loadConfigFromBranchToRoot(dir: PortablePath): Promise<ProcessedConfig<T>> {
const configs = await this.getConfigFinder().loadFromBranchToRoot(dir);
return this.processConfigs(this.onLoadedConfig.emit(configs));
}
/**
* Load config files from the defined root. Root is determined by a relative
* `.config` folder and `package.json` file.
*/
async loadConfigFromRoot(fromDir: PortablePath = process.cwd()): Promise<ProcessedConfig<T>> {
const configs = await this.getConfigFinder().loadFromRoot(fromDir);
return this.processConfigs(this.onLoadedConfig.emit(configs));
}
/**
* Traverse upwards from the branch directory, until the root directory is found,
* or we reach to top of the file system. While traversing, find all ignore files
* within each branch directory, and the root.
*/
async loadIgnoreFromBranchToRoot(dir: PortablePath): Promise<IgnoreFile[]> {
const ignores = await this.getIgnoreFinder().loadFromBranchToRoot(dir);
return this.onLoadedIgnore.emit(ignores);
}
/**
* Load ignore file from the defined root. Root is determined by a relative
* `.config` folder and `package.json` file.
*/
async loadIgnoreFromRoot(dir: PortablePath = process.cwd()): Promise<IgnoreFile[]> {
const ignores = await this.getIgnoreFinder().loadFromRoot(dir);
return this.onLoadedIgnore.emit(ignores);
}
/**
* Explicitly set the root directory to stop traversal at. This should only be set
* manually when you want full control, and know file boundaries up front.
*
* This *does not* check for the existence of the root config file or folder.
*/
setRootDir(dir: PortablePath): this {
this.cache.setRootDir(dir);
return this;
}
/**
* Add a process handler to customize the processing of key-value setting pairs.
* May only run a processor on settings found in the root of the configuration object.
* @public
*/
protected addProcessHandler<K extends keyof T, V = T[K]>(key: K, handler: Handler<V>): this {
this.getProcessor().addHandler(key, handler);
return this;
}
/**
* Life cycle called on initialization.
* @public
*/
protected bootstrap() {}
/**
* Configure the finder instance.
* @public
*/
protected configureFinder(options: Omit<ConfigFinderOptions<T>, 'name'>): this {
this.getConfigFinder().configure(options);
return this;
}
/**
* Configure the processor instance.
* @public
*/
protected configureProcessor(options: Omit<ProcessorOptions, 'name'>): this {
this.getProcessor().configure(options);
return this;
}
/**
* Return the config file finder instance.
*/
protected getConfigFinder(): ConfigFinder<T> {
return this.configFinder;
}
/**
* Return the ignore file finder instance.
*/
protected getIgnoreFinder(): IgnoreFinder {
return this.ignoreFinder;
}
/**
* Return the processor instance.
*/
protected getProcessor(): Processor<T> {
return this.processor;
}
/**
* Process all loaded config objects into a single config object, and then validate.
*/
protected async processConfigs(files: ConfigFile<T>[]): Promise<ProcessedConfig<T>> {
const config = await this.getProcessor().process(
this.options,
files,
this.blueprint(schemas) as Blueprint<T>,
);
this.onProcessedConfig.emit([config]);
return {
config,
files,
};
}
}