@boost/core
Version:
Robust pipeline for creating dev tools that separate logic into routines and tasks.
356 lines (355 loc) • 15.5 kB
JavaScript
"use strict";
/* eslint-disable no-cond-assign */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const chalk_1 = __importDefault(require("chalk"));
const fs_extra_1 = __importDefault(require("fs-extra"));
const fast_glob_1 = __importDefault(require("fast-glob"));
const path_1 = __importDefault(require("path"));
const json5_1 = __importDefault(require("json5"));
const camelCase_1 = __importDefault(require("lodash/camelCase"));
const mergeWith_1 = __importDefault(require("lodash/mergeWith"));
const debug_1 = require("@boost/debug");
const optimal_1 = __importStar(require("optimal"));
const common_1 = require("@boost/common");
const formatModuleName_1 = __importDefault(require("./helpers/formatModuleName"));
const handleMerge_1 = __importDefault(require("./helpers/handleMerge"));
const constants_1 = require("./constants");
class ConfigLoader {
constructor(tool) {
this.package = { name: '', version: '0.0.0' };
this.parsedFiles = new Set();
this.workspaceRoot = '';
this.debug = debug_1.createDebugger('config-loader');
this.tool = tool;
}
/**
* Find the config using the --config file path option.
*/
findConfigFromArg(filePath) {
this.debug.invariant(!!filePath, 'Looking in --config command line option', 'Found', 'Not found');
return filePath ? { extends: [filePath] } : null;
}
/**
* Find the config in the package.json block under the application name.
*/
findConfigInPackageJSON(pkg) {
const configName = this.getConfigName();
const config = pkg[configName];
this.debug.invariant(!!config, `Looking in package.json under ${chalk_1.default.yellow(configName)} property`, 'Found', 'Not found');
if (!config) {
return null;
}
// Extend from a preset if a string
if (typeof config === 'string') {
return { extends: [config] };
}
return config;
}
/**
* Find the config using local files commonly located in a configs/ folder.
*/
findConfigInLocalFiles(root) {
const configName = this.getConfigName();
const relPath = `configs/${configName}.{js,json,json5}`;
const configPaths = fast_glob_1.default.sync(relPath, {
absolute: true,
cwd: root,
onlyFiles: true,
});
this.debug.invariant(configPaths.length === 1, `Looking for local config file: ${relPath}`, 'Found', 'Not found');
if (configPaths.length === 1) {
this.debug('Found %s', chalk_1.default.cyan(path_1.default.basename(configPaths[0])));
return configPaths[0];
}
if (configPaths.length > 1) {
throw new Error(this.tool.msg('errors:multipleConfigFiles', { configName }));
}
return null;
}
/**
* Find the config within the root when in a workspace.
*/
// eslint-disable-next-line complexity
findConfigInWorkspaceRoot(root) {
let currentDir = path_1.default.dirname(root);
if (currentDir.includes('node_modules')) {
return null;
}
this.debug('Detecting if in a workspace');
let workspaceRoot = '';
let workspacePackage = {};
let workspacePatterns = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (!currentDir || currentDir === '.' || currentDir === '/') {
break;
}
const pkgPath = path_1.default.join(currentDir, 'package.json');
if (fs_extra_1.default.existsSync(pkgPath)) {
workspacePackage = this.parseFile(pkgPath);
}
workspaceRoot = currentDir;
workspacePatterns = this.tool.getWorkspacePaths({
relative: true,
root: currentDir,
});
if (workspacePackage && workspacePatterns.length > 0) {
break;
}
currentDir = path_1.default.dirname(currentDir);
}
if (!workspaceRoot) {
this.debug('No workspace found');
return null;
}
const match = workspacePatterns.some((pattern) => !!root.match(new RegExp(path_1.default.join(workspaceRoot, pattern), 'u')));
this.debug.invariant(match, `Matching patterns: ${workspacePatterns.map(p => chalk_1.default.cyan(p)).join(', ')}`, 'Match found', 'Invalid workspace package');
if (!match) {
return null;
}
this.workspaceRoot = workspaceRoot;
return (this.findConfigInPackageJSON(workspacePackage) ||
this.findConfigInLocalFiles(workspaceRoot) ||
null);
}
/**
* Return the config name used for file names and the package.json property.
*/
getConfigName() {
const { configName, appName } = this.tool.options;
return configName || camelCase_1.default(appName);
}
/**
* Inherit configuration settings from defined CLI options.
*/
inheritFromArgs(config, args) {
const nextConfig = Object.assign({}, config);
const pluginTypes = this.tool.getRegisteredPlugins();
const keys = new Set([
'debug',
'locale',
'output',
'silent',
'theme',
...Object.keys(this.tool.options.configBlueprint),
]);
this.debug('Inheriting config from CLI options');
Object.keys(args).forEach(key => {
const value = args[key];
if (key === 'config' || key === 'extends' || key === 'settings') {
return;
}
// @ts-ignore Ignore symbol check
const pluginType = pluginTypes[key];
// Plugins
if (pluginType) {
const { pluralName, singularName } = pluginType;
this.debug(' --%s=[%s]', singularName, value.join(', '));
nextConfig[pluralName] = (nextConfig[pluralName] || []).concat(value);
// Other
}
else if (keys.has(key)) {
this.debug(' --%s=%s', key, value);
nextConfig[key] = value;
}
});
return nextConfig;
}
/**
* Load a local configuration file relative to the current working directory,
* or from within a package.json property of the same appName.
*
* Support both JSON and JS file formats by globbing the config directory.
*/
loadConfig(args) {
if (common_1.isEmpty(this.package) || !this.package.name) {
throw new Error(this.tool.msg('errors:packageJsonNotLoaded'));
}
this.debug('Locating configuration');
const { configBlueprint, settingsBlueprint, root } = this.tool.options;
const pluginsBlueprint = {};
const configPath = this.findConfigFromArg(args.config) ||
this.findConfigInPackageJSON(this.package) ||
this.findConfigInLocalFiles(root) ||
this.findConfigInWorkspaceRoot(root);
if (!configPath) {
throw new Error(this.tool.msg('errors:configNotFound'));
}
Object.values(this.tool.getRegisteredPlugins()).forEach(type => {
const { contract, singularName, pluralName } = type;
this.debug('Generating %s blueprint', chalk_1.default.magenta(singularName));
// prettier-ignore
pluginsBlueprint[pluralName] = optimal_1.array(optimal_1.union([
optimal_1.string().notEmpty(),
optimal_1.shape({ [singularName]: optimal_1.string().notEmpty() }),
optimal_1.instance(contract, true),
], []));
});
const config = optimal_1.default(this.inheritFromArgs(this.parseAndExtend(configPath), args), Object.assign({}, configBlueprint, pluginsBlueprint, { debug: optimal_1.bool(), extends: optimal_1.array(optimal_1.string()), locale: optimal_1.string(), output: optimal_1.number(2).between(1, 3, true),
// shape() requires a non-empty object
settings: common_1.isEmpty(settingsBlueprint) ? optimal_1.object() : optimal_1.shape(settingsBlueprint), silent: optimal_1.bool(), theme: optimal_1.string('default').notEmpty() }), {
file: typeof configPath === 'string' ? path_1.default.basename(configPath) : '',
name: 'ConfigLoader',
unknown: true,
});
return config;
}
/**
* Load the "package.json" from the current working directory,
* as we require the dev tool to be ran from the project root.
*/
loadPackageJSON() {
const { root } = this.tool.options;
const filePath = path_1.default.join(root, 'package.json');
this.debug('Locating package.json in %s', chalk_1.default.cyan(root));
if (!fs_extra_1.default.existsSync(filePath)) {
throw new Error(this.tool.msg('errors:packageJsonNotFound'));
}
this.package = optimal_1.default(this.parseFile(filePath), {
name: optimal_1.string().notEmpty(),
version: optimal_1.string('0.0.0'),
}, {
file: 'package.json',
name: 'ConfigLoader',
unknown: true,
});
return this.package;
}
/**
* If an `extends` option exists, recursively merge the current configuration
* with the preset configurations defined within `extends`,
* and return the new configuration object.
*/
parseAndExtend(fileOrConfig) {
let config;
let baseDir = '';
// Parse out the object if a file path
if (typeof fileOrConfig === 'string') {
config = this.parseFile(fileOrConfig);
baseDir = path_1.default.dirname(fileOrConfig);
}
else {
config = fileOrConfig;
}
// Verify we're working with an object
if (!common_1.isObject(config)) {
throw new Error(this.tool.msg('errors:configInvalid'));
}
const { extends: extendPaths } = config;
// Nothing to extend, so return the current config
if (!extendPaths || extendPaths.length === 0) {
return config;
}
// Resolve extend paths and inherit their config
const nextConfig = {};
const resolvedPaths = this.resolveExtendPaths(extendPaths, baseDir);
resolvedPaths.forEach(extendPath => {
if (this.parsedFiles.has(extendPath)) {
return;
}
if (!fs_extra_1.default.existsSync(extendPath)) {
throw new Error(this.tool.msg('errors:presetConfigNotFound', { extendPath }));
}
else if (!fs_extra_1.default.statSync(extendPath).isFile()) {
throw new Error(this.tool.msg('errors:presetConfigInvalid', { extendPath }));
}
this.debug('Extending from file %s', chalk_1.default.cyan(extendPath));
mergeWith_1.default(nextConfig, this.parseAndExtend(extendPath), handleMerge_1.default);
});
// Apply the current config after extending preset configs
config.extends = resolvedPaths;
mergeWith_1.default(nextConfig, config, handleMerge_1.default);
return nextConfig;
}
/**
* Parse a configuration file at the defined file system path.
* If the file ends in "json" or "json5", parse it with JSON5.
* If the file ends in "js", import the file and use the default object.
* Otherwise throw an error.
*/
parseFile(filePath, args = [], options = {}) {
const name = path_1.default.basename(filePath);
const ext = path_1.default.extname(filePath);
let value = null;
this.debug('Parsing file %s', chalk_1.default.cyan(filePath));
if (!path_1.default.isAbsolute(filePath)) {
throw new Error(this.tool.msg('errors:absolutePathRequired'));
}
if (ext === '.json' || ext === '.json5') {
value = json5_1.default.parse(fs_extra_1.default.readFileSync(filePath, 'utf8'));
}
else if (ext === '.js') {
value = common_1.requireModule(filePath);
if (typeof value === 'function') {
if (options.errorOnFunction) {
throw new Error(this.tool.msg('errors:configNoFunction', { name }));
}
else {
value = value(...args);
}
}
}
else {
throw new Error(this.tool.msg('errors:configUnsupportedExt', { ext }));
}
if (!common_1.isObject(value)) {
throw new Error(this.tool.msg('errors:configInvalidNamed', { name }));
}
this.parsedFiles.add(filePath);
return value;
}
/**
* Resolve file system paths for the `extends` configuration value
* using the following guidelines:
*
* - Absolute paths should be normalized and used as is.
* - Relative paths should be resolved relative to the CWD.
* - Strings that match a node module name should resolve to a config file relative to the CWD.
* - Strings that start with "<plugin>:" should adhere to the previous rule.
*/
resolveExtendPaths(extendPaths, baseDir = '') {
return extendPaths.map(extendPath => {
if (typeof extendPath !== 'string') {
throw new TypeError(this.tool.msg('errors:configExtendsInvalid'));
}
const { appName, scoped, root } = this.tool.options;
const configName = this.getConfigName();
let match = null;
// Absolute path, use it directly
if (path_1.default.isAbsolute(extendPath)) {
return path_1.default.normalize(extendPath);
// Relative path, resolve with parent folder or cwd
}
else if (extendPath[0] === '.') {
return path_1.default.resolve(baseDir || root, extendPath);
// Node module, resolve to a config file
}
else if (extendPath.match(constants_1.MODULE_NAME_PATTERN)) {
return this.resolveModuleConfigPath(configName, extendPath, true);
// Plugin, resolve to a node module
}
else if ((match = extendPath.match(constants_1.PLUGIN_NAME_PATTERN))) {
return this.resolveModuleConfigPath(configName, formatModuleName_1.default(appName, match[1], extendPath, scoped), true);
}
throw new Error(this.tool.msg('errors:configExtendsInvalidPath', { extendPath }));
});
}
/**
* Resolve a Node/NPM module path to an app config file.
*/
resolveModuleConfigPath(configName, moduleName, preset = false, ext = 'js') {
const fileName = preset ? `${configName}.preset.${ext}` : `${configName}.${ext}`;
return path_1.default.resolve(this.tool.options.root, 'node_modules', moduleName, 'configs', fileName);
}
}
exports.default = ConfigLoader;