Configuration
Powerful convention based finder, loader, and manager of both configuration and ignore files. Will find config files of multiple supported formats while traversing up the tree.
Installation
- Yarn
- pnpm
- npm
yarn add @boost/config
pnpm add @boost/config
npm install @boost/config
Setup
Configuration in the context of this package encompasses 2 concepts: config files and ignore files. Config files are a collection of settings (key-value pairs), while ignore files are a list of file path patterns and globs.
To utilize this functionality, we must extend the Configuration
class, and define
a blueprint for the structure of our config file (using optimal).
This class will fulfill multiple roles: managing, finding, loading, and processing of files.
import { Blueprint, Schemas } from '@boost/common';
import { Configuration } from '@boost/config';
// Example structure
interface ConfigFile {
ast?: boolean;
cwd?: string;
debug?: boolean;
exclude?: string[];
include?: string[];
options?: object;
}
class Manager extends Configuration<ConfigFile> {
blueprint({ array, bool, string, object }: Schemas): Blueprint<ConfigFile> {
return {
ast: bool(),
cwd: string(process.cwd()),
debug: bool(),
exclude: array().of(string()),
include: array().of(string()),
options: object(),
};
}
}
This class layer is designed to be "internal only", and should not be utilized by consumers directly. Instead, consumers should interact with an instance of the class, like so.
export default new Manager('boost');
The string value passed to the constructor is the name of the config and ignore files, in camel case format. For example,
boost.js
and.boostignore
.
Finder options
To customize the config file finding and loading layer, call
Configuration#configureFinder()
within
#bootstrap()
. This method supports all options in
ConfigFinderOptions
except for name
.
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
this.configureFinder({
extendsSetting: 'extends',
includeEnv: false,
});
}
}
Processor options
To customize the config processing layer, call
Configuration#configureProcessor()
while
within #bootstrap()
. This method supports all options
in ProcessorOptions
except for name
.
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
this.configureProcessor({
defaultWhenUndefined: false,
});
}
}
Processing settings
When multiple config files are merged into a single config file, this is known as processing. Processing happens automatically for each setting as we need to determine what the next setting value would be. By default, the following rules apply when the next and previous setting values are:
- Arrays: will be merged and deduped into a new array.
- Objects: will be shallow merged (using spread) into a new object.
- Primitives: next value will overwrite the previous value.
- Undefined: will reset to initial value if
defaultWhenUndefined
is true.
If you would like to customize this process, you can define custom process handlers per setting with
Configuration#addProcessHandler()
. This
method requires a setting name and handler function (which is passed the previous and next values).
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
// Always use forward slashes
this.addProcessHandler('cwd', (prev, next) => next.replace(/\\/g, '/'));
// Deep merge options since they're dynamic
this.addProcessHandler('options', (prev, next) => deepMerge(prev, next));
}
}
Handlers may only be defined on root-level settings.
To make this process even easier, we provide a handful of pre-defined handlers (below) that can be used for common scenarios (these handlers power the default rules mentioned above).
mergeArray
- Merges previous and next arrays into a new array while removing duplicates (usingSet
).mergeExtends
- Merges previous and next file paths (either a string or array of strings) into a new list of file paths. This is useful if utilizing config extending.mergeObject
- Shallow merges previous and next objects into a new object using object spread.mergePlugins
- Merges previous and next plugin configurations into an object. Plugin configs can either be a list of sources, or list of sources with flags/options (tuples), or a map of sources to flags/options. This is useful if utilizing the plugin package.overwrite
- Overwrite the previous value with the next value.
import { mergePlugins } from '@boost/config';
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
// Using example from @boost/plugin documentation
this.addProcessHandler('renderers', mergePlugins);
}
}
Config files
A config file is a file that explicitly defines settings (key-value pairs) according to a defined structure.
Configuration files are designed to be serializable, so please use primitive, object, and array values only. Try to avoid non-serializable values like class instances.
File patterns
Config files are grouped into either the root or branch category. The root of a project is denoted
by a root *.config.*
file, or a folder with the name .config
, which contains config files.
Branch config files are located within folders (at any depth) below the root, and are prefixed with
a leading dot (.
).
Root | Branch |
---|---|
.config/<name>.<ext> , <name>.config.<ext> | .<name>.<ext> |
.config/<name>.<env>.<ext> , <name>.config.<env>.<ext> | .<name>.<env>.<ext> |
<name>
- Name passed to yourConfiguration
instance (in camel case).<env>
- Current environment derived fromNODE_ENV
.<ext>
- File extension supported by the defined loaders and extensions.
File formats
Config files can be written in the formats below, and are listed in the order in which they're
resolved (can customize with the extensions
option).
.js
- JavaScript. Will load with CommonJS or ECMAScript modules depending on thepackage.json
type
field. Defaults to CommonJS if not defined..json
,.json5
- JSON. Supports JSON5 for both extensions..cjs
- JavaScript using CommonJS (require()
). Supported by all Node.js versions..mjs
- JavaScript using ECMAScript modules (import
/export
). Requires Node.js v13.3+..ts
- TypeScript. Requires thetypescript
package..yaml
,.yml
- YAML. Does not support multi-document.
Based on the file structure in the Setup section above, the config files can be demonstrated as followed (excluding standard JavaScript since it's either CJS or MJS).
- JavaScript (CJS)
- JavaScript (MJS)
- TypeScript
- JSON
- YAML
module.exports = {
ast: false,
debug: true,
exclude: ['**/node_modules/**'],
include: ['src/**', 'tests/**'],
options: { experimental: true },
};
export default {
ast: false,
debug: true,
exclude: ['**/node_modules/**'],
include: ['src/**', 'tests/**'],
options: { experimental: true },
};
import type { ConfigFile } from './types';
const config: ConfigFile = {
ast: false,
debug: true,
exclude: ['**/node_modules/**'],
include: ['src/**', 'tests/**'],
options: { experimental: true },
};
export default config;
{
"ast": false,
"debug": true,
"exclude": ["**/node_modules/**"],
"include": ["src/**", "tests/**"],
"options": { "experimental": true }
}
ast: false
debug: true
exclude:
- '**/node_modules/**'
include:
- 'src/**'
- 'tests/**'
options:
experimental: true
Loading config files
Config files can be found and loaded with either the
Configuration#loadConfigFromRoot()
or
Configuration#loadConfigFromBranchToRoot()
methods -- both of which
return a processed config object that abides the
ProcessedConfig
type.
Lookup resolution
When the finder traverses through the file system and attempts to resolve config files within each/target folder, it does so using the lookup algorithm demonstrated below. Let's assume the following:
- The config file name is
boost
. - All file formats are supported, in their default lookup order (js, json, cjs, mjs, ts, json5, yaml, yml).
- The current environment is
development
(the value ofNODE_ENV
).
boost.js
boost.development.js
boost.json
boost.development.json
boost.cjs
boost.development.cjs
boost.mjs
boost.development.mjs
boost.ts
boost.development.ts
boost.json5
boost.development.json5
boost.yaml
boost.development.yaml
boost.yml
boost.development.yml
For each file format, we attempt to find the base config file, and an environment config file (if
includeEnv
is true). This allows for higher precendence config per environment.
Once a file is found, the lookup process is aborted, and the confg is returned.
Only 1 file format will be used per folder. Multiple file formats is not supported.
From root
The Configuration#loadConfigFromRoot()
will load the config file found in
the root, either a *.config.*
or .config/*.*
file. If no root path is provided, it defaults to
process.cwd()
.
{
"debug": true
}
const { config } = await manager.loadConfigFromRoot('/root');
{
config: { debug: true },
files: [
{
config: { debug: true },
path: new Path('/root/.config/boost.json'),
source: 'root',
},
],
}
From branch
The Configuration#loadConfigFromBranchToRoot()
method will load a
config file from each folder while traversing upwards from the branch folder to the root folder. The
found list is returned in reverse order so that the deepest branch can be used to overwrite the
previous branch (or root).
export default {
ast: true,
};
options:
experimental: true
{
"debug": true
}
const { config } = await manager.loadConfigFromBranchToRoot('/root/modules/features');
{
config: {
ast: true,
debug: true,
options: {
experimental: true,
},
},
files: [
{
config: { debug: true },
path: new Path('/root/.config/boost.json'),
source: 'root',
},
{
config: {
options: {
experimental: true,
},
},
path: new Path('/root/modules/.boost.yaml'),
source: 'branch',
},
{
config: { ast: true },
path: new Path('/root/modules/features/.boost.mjs'),
source: 'branch',
},
],
};
Enable extending
Config extending enables consumers of your project to extend and merge with external config files
using file system paths or Node.js modules, with the current config file taking
precedence. With that being said, extending is not enabled by default and must be configured for
use. To enable, define the extendsSetting
option with the name of a setting in
which extending would be configured.
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
this.configureFinder({
extendsSetting: 'extend',
});
}
}
Consumers may now extend external config files by defining a string or an array of strings for
extend
(name derived from the example above).
export default {
extend: ['./some/relative/path.js', 'npm-module'],
debug: false,
};
File paths are relative to the file it's configured in.
Presets
To extend from a Node.js module, we must use a preset. A preset is a
JavaScript config file located in the module root, named in the format of
<name>.preset.js
.
module.exports = {
exclude: ['**/node_modules'],
};
Since the preset is JavaScript, it can be written in either CommonJS or ECMAScript format, assuming the
type
field has been set inpackage.json
.
Enable overrides
Config overrides enables consumers of your project to define granular settings based on file path
matching; settings defined in this fashion would override their base settings. With that being said,
overrides are not enabled by default and must be configured for use. To enable, define the
overridesSetting
option with the name of a setting in which overrides would be
configured.
class Manager extends Configuration<ConfigFile> {
// ...
bootstrap() {
this.configureFinder({
overridesSetting: 'override',
});
}
}
Overrides are extracted before configurations are processed, so a process handler is not required.
Consumers may now define overrides in their config file by passing a list of items to the override
setting (name derived from the example above). Each item must abide the
OverridesSettingItem
type.
export default {
debug: false,
override: [
{
include: '*.test.ts',
settings: {
debug: true,
},
},
],
};
Ignore files
An ignore file is a standard text file that denotes files and folders to ignore (filter/exclude/etc), within the current directory, using matching globs and patterns.
File patterns
Both root and branch level ignore files use the same file naming scheme. The file is prefixed with a
leading dot (.
), followed by the name passed to your Configuration
instance (in
camel case), and suffixed with ignore
.
Root | Branch |
---|---|
.<name>ignore | .<name>ignore |
The root ignore file is not located within the
.config
folder as ignore paths/patterns/globs must be relative to the current directory.
Loading ignore files
Ignore files can be found and loaded with either the
Configuration#loadIgnoreFromRoot()
or
Configuration#loadIgnoreFromBranchToRoot()
methods -- both of which
return a list of ignore metadata that abide the IgnoreFile
type.
To demonstrate this, let's assume the following file system.
root/
├── modules/
│ ├── features/
│ │ ├── index.ts
│ │ └── .boostignore
│ ├── foo.ts
│ ├── bar.ts
│ └── baz.ts
└── .boostignore
build/
*.log
*.lock
From root
The Configuration#loadIgnoreFromRoot()
will load the ignore file found in
the root folder (typically 1 file). If no root path is provided, it defaults to process.cwd()
.
const list = await manager.loadIgnoreFromRoot('/root');
[
{
ignore: ['*.log', '*.lock'],
path: new Path('/root/.boostignore'),
source: 'root',
},
];
From branch
The Configuration#loadIgnoreFromBranchToRoot()
method will load an
ignore file from each folder while traversing upwards from the branch folder to the root folder. The
found list is returned in reverse order so that the deepest branch can be used to overwrite the
previous branch (or root).
const list = await manager.loadIgnoreFromBranchToRoot('/root/modules/features');
[
{
ignore: ['*.log', '*.lock'],
path: new Path('/root'),
source: 'root',
},
{
ignore: ['build/'],
path: new Path('/root/modules/features/.boostignore'),
source: 'branch',
},
];