UNPKG

roc

Version:

Build modern web applications easily

482 lines (416 loc) 16.2 kB
import 'source-map-support/register'; import chalk from 'chalk'; import { isPlainObject, isBoolean, isString, set, difference } from 'lodash'; import resolve from 'resolve'; import leven from 'leven'; import trimNewlines from 'trim-newlines'; import redent from 'redent'; import { merge } from '../configuration'; import buildDocumentationObject from '../documentation/build-documentation-object'; import generateTable from '../documentation/generate-table'; import { getDefaultValue } from '../documentation/helpers'; import { fileExists, getRocDependencies, getPackageJson } from '../helpers'; import { throwError } from '../validation'; import { isValid } from '../validation'; import { warning, importantLabel, errorLabel, warningLabel } from '../helpers/style'; /** * Builds the complete configuration objects. * * @param {boolean} debug - If debug mode should be enabled, logs some extra information. * @param {rocConfig} config - The base configuration. * @param {rocMetaConfig} meta - The base meta configuration. * @param {rocConfig} newConfig - The new configuration to base the merge on. * @param {rocMetaConfig} newMeta - The new meta configuration to base the merge on. * @param {string} [directory=process.cwd()] - The directory to resolve relative paths from. * @param {boolean} [validate=true] - If the newConfig and the newMeta structure should be validated. * * @returns {Object} - The result of with the built configurations. * @property {rocConfig} extensionConfig - The extensions merged configurations * @property {rocConfig} config - The final configuration, with application configuration. * @property {rocMetaConfig} meta - The merged meta configuration. */ export function buildCompleteConfig( debug, config = {}, meta = {}, newConfig = {}, newMeta = {}, directory = process.cwd(), validate = true ) { let finalConfig = { ...config }; let finalMeta = { ...meta }; let usedExtensions = []; const mergeExtension = (extensionName) => { const { baseConfig, metaConfig = {} } = getExtension(extensionName, directory); if (baseConfig) { usedExtensions.push(extensionName); finalConfig = merge(finalConfig, baseConfig); finalMeta = merge(finalMeta, metaConfig); } }; if (fileExists('package.json', directory)) { // If extensions are defined we will use them to merge the configurations if (newConfig.extensions && newConfig.extensions.length) { newConfig.extensions.forEach(mergeExtension); } else { const packageJson = getPackageJson(directory); getRocDependencies(packageJson) .forEach(mergeExtension); } if (usedExtensions.length && debug) { console.log(importantLabel('The following Roc extensions will be used:'), usedExtensions, '\n'); } // Check for a mismatch between application configuration and extensions. if (validate) { if (Object.keys(newConfig).length) { console.log(validateConfigurationStructure(finalConfig, newConfig)); } if (Object.keys(newMeta).length) { console.log(validateConfigurationStructure(finalMeta, newMeta)); } } } return { extensionConfig: finalConfig, config: merge(finalConfig, newConfig), meta: merge(finalMeta, newMeta) }; } function getExtension(extensionName, directory) { try { const { baseConfig, metaConfig } = require(resolve.sync(extensionName, { basedir: directory })); return { baseConfig, metaConfig }; } catch (err) { console.log( errorLabel( 'Failed to load Roc extension ' + chalk.bold(extensionName) + '. ' + 'Make sure you have it installed. Try running:' ) + ' ' + chalk.underline('npm install --save ' + extensionName) , '\n'); return {}; } } function validateConfigurationStructure(config, applicationConfig) { const getKeys = (obj, oldPath = '', allKeys = []) => { Object.keys(obj).forEach((key) => { const value = obj[key]; const newPath = oldPath + key; if (isPlainObject(value)) { getKeys(value, newPath + '.', allKeys); } else { allKeys.push(newPath); } }); return allKeys; }; const info = []; const keys = getKeys(config); const diff = difference(getKeys(applicationConfig), keys); if (diff.length > 0) { info.push(errorLabel('Configuration problem') + ' There was a mismatch in the application configuration structure, make sure this is correct.\n'); info.push(getSuggestions(diff, keys)); info.push(''); } // } return info.join('\n'); } /** * Will create a string with suggestions for possible typos. * * @param {string[]} current - The current values that might be incorrect. * @param {string[]} possible - All the possible correct values. * @param {boolean} [command=false] - If the suggestion should be managed as a command. * * @returns {string} - A string with possible suggestions for typos. */ export function getSuggestions(current, possible, command = false) { const info = []; current.forEach((currentKey) => { let shortest = 0; let closest; for (let key of possible) { let distance = leven(currentKey, key); if (distance <= 0 || distance > 4) { continue; } if (shortest && distance >= shortest) { continue; } closest = key; shortest = distance; } const extra = command ? '--' : ''; if (closest) { info.push('Did not understand ' + chalk.underline(extra + currentKey) + ' - Did you mean ' + chalk.underline(extra + closest)); } else { info.push('Did not understand ' + chalk.underline(extra + currentKey)); } }); return info.join('\n'); } /** * Generates a string with information about all the possible commands. * * @param {rocConfig} commands - The Roc config object, uses commands from it. * @param {rocMetaConfig} commandsmeta - The Roc meta config object, uses commands from it. * * @returns {string} - A string with documentation based on the available commands. */ export function generateCommandsDocumentation({ commands }, { commands: commandsMeta }) { const header = { name: true, description: true }; const noCommands = {'No commands available.': ''}; commandsMeta = commandsMeta || {}; let body = [{ name: 'Commands', objects: Object.keys(commands || noCommands).map((command) => { const options = commandsMeta[command] ? ' ' + getCommandOptionsAsString(commandsMeta[command]) : ''; const description = commandsMeta[command] && commandsMeta[command].description ? commandsMeta[command].description : ''; return { name: (command + options), description }; }) }]; return generateCommandDocsHelper(body, header, 'Options', 'name'); } function getCommandOptionsAsString(command = {}) { let options = ''; (command.options || []).forEach((option) => { options += option.required ? `<${option.name}> ` : `[${option.name}] `; }); return options; } /** * Generates a string with information about a specific command. * * @param {rocConfig} settings - The Roc config object, uses settings from it. * @param {rocMetaConfig} commands+meta - The Roc meta config object, uses commands and settings from it. * @param {string} command - The selected command. * @param {string} name - The name of the cli. * * @returns {string} - A string with documentation based on the selected commands. */ export function generateCommandDocumentation({ settings }, { commands = {}, settings: meta }, command, name) { const rows = []; rows.push('Usage: ' + name + ' ' + command + ' ' + getCommandOptionsAsString(commands[command])); rows.push(''); if (commands[command] && commands[command].help) { rows.push(redent(trimNewlines(commands[command].help))); rows.push(''); } let body = []; // Generate the options table if (commands[command] && commands[command].settings) { rows.push('Options:'); rows.push(''); const filter = commands[command].settings === true ? [] : commands[command].settings; body = buildDocumentationObject(settings, meta, filter); } const header = { cli: true, description: { name: 'Description', padding: false }, defaultValue: { name: 'Default', renderer: (input) => { input = getDefaultValue(input); if (input === undefined) { return ''; } if (!input) { return warning('No default value'); } return chalk.cyan(input); } } }; rows.push(generateCommandDocsHelper(body, header, 'CLI options', 'cli')); return rows.join('\n'); } function generateCommandDocsHelper(body, header, options, name) { body.push({ name: options, objects: [{ [name]: '-h, --help', description: 'Output usage information.' }, { [name]: '-v, --version', description: 'Output version number.' }, { [name]: '-d, --debug', description: 'Enable debug mode.' }, { [name]: '-c, --config', description: `Path to configuration file, will default to ${chalk.bold('roc.config.js')} in current ` + `working directory.` }, { [name]: '-D, --directory', description: 'Path to working directory, will default to the current working directory. Can be either ' + 'absolute or relative.' }] }); return generateTable(body, header, { compact: true, titleWrapper: (input) => input + ':', cellDivider: '', rowWrapper: (input) => `${input}`, header: false, groupTitleWrapper: (input) => input + ':' }); } /** * Parses options and validates them. * * @param {string} command - The command to parse options for. * @param {Object} commands - commands from {@link rocMetaConfig}. * @param {Object[]} options - Options parsed by minimist. * * @returns {Object} - Parsed options. * @property {object[]} options - The parsed options that was matched against the meta configuration for the command. * @property {object[]} rest - The rest of the options that could not be matched against the configuration. */ export function parseOptions(command, commands, options) { // If the command supports options if (commands[command] && commands[command].options) { let parsedOptions = {}; commands[command].options.forEach((option, index) => { const value = options[index]; if (option.required && !value) { throw new Error(`Required option "${option.name}" was not provided.`); } if (value && option.validation) { const validationResult = isValid(value, option.validation); if (validationResult !== true) { try { throwError(option.name, validationResult, value, 'option'); } catch (err) { /* eslint-disable no-process-exit, no-console */ console.log(errorLabel('Arguments problem') + ' An option was not valid.\n'); console.log(err.message); process.exit(1); /* eslint-enable */ } } } parsedOptions[option.name] = value; }); return { options: parsedOptions, rest: options.splice(Object.keys(parsedOptions).length) }; } return { options: undefined, rest: options }; } /** * Creates mappings between cli commands to their "path" in the configuration structure, their validator and type * convertor. * * @param {rocDocumentationObject} documentationObject - Documentation object to create mappings for. * * @returns {Object} - Properties are the cli command without leading dashes that maps to a {@link rocMapObject}. */ export function getMappings(documentationObject) { const recursiveHelper = (groups) => { let mappings = {}; groups.forEach((group) => { group.objects.forEach((element) => { // Remove the two dashes in the beginning to match correctly mappings[element.cli.substr(2)] = { name: element.cli, path: element.path, convertor: getConvertor(element.defaultValue, element.cli), validator: element.validator }; }); mappings = Object.assign({}, mappings, recursiveHelper(group.children)); }); return mappings; }; return recursiveHelper(documentationObject); } // Convert values based on their default value function getConvertor(value, name) { if (isBoolean(value)) { return (input) => { if (isBoolean(input)) { return input; } if (input === 'true' || input === 'false') { return input === 'true'; } console.log( warningLabel(`Invalid value given for ${chalk.bold(name)}.`), `Will use the default ${chalk.bold(value)}.` ); return value; }; } else if (Array.isArray(value)) { return (input) => { let parsed; try { parsed = JSON.parse(input); } catch (err) { // Ignore this case } if (Array.isArray(parsed)) { return parsed; } return input.toString().split(','); }; } else if (Number.isInteger(value)) { return (input) => parseInt(input, 10); } else if (!isString(value) && (!value || Object.keys(value).length === 0)) { return (input) => JSON.parse(input); } return (input) => input; } /** * Converts a set of arguments to {@link rocConfigSettings} object. * * @param {Object} args - Arguments parsed from minimist. * @param {Object} mappings - Result from {@link getMappings}. * * @returns {Object} - The mapped Roc configuration settings object. */ export function parseArguments(args, mappings) { const config = {}; const info = []; Object.keys(args).forEach((key) => { if (mappings[key]) { const value = convert(args[key], mappings[key]); set(config, mappings[key].path, value); } else { // We did not find a match info.push(getSuggestions([key], Object.keys(mappings), true)); } }); if (info.length > 0) { console.log(errorLabel('CLI problem'), 'Some commands were not understood.\n'); console.log(info.join('\n') + '\n'); } return config; } function convert(value, mapping) { const val = mapping.convertor(value); const validationResult = isValid(val, mapping.validator); if (validationResult === true) { return val; } console.log( warning(`There was a problem when trying to automatically convert ${chalk.bold(mapping.name)}. This ` + `value will be ignored.`) ); console.log( `Received ${chalk.underline(value)} and it was converted to ${chalk.underline(val)}.`, validationResult, '\n' ); }