UNPKG

@boost/core

Version:

Robust pipeline for creating dev tools that separate logic into routines and tasks.

356 lines (355 loc) 15.5 kB
"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;