From b71cfe711e4c214f194219581214b9f5e98dba2c Mon Sep 17 00:00:00 2001 From: Matt Stypa Date: Thu, 13 Sep 2018 21:37:10 -0500 Subject: [PATCH] CLI tool rewrite --- __tests__/cli.test.js | 58 +++++++--- __tests__/customConfig.test.js | 2 +- .../{customConfig.js => custom-config.js} | 0 __tests__/fixtures/tailwind-cli-input.css | 3 - __tests__/fixtures/tailwind-cli-output.css | 3 - package.json | 9 +- src/cli.js | 104 ++--------------- src/cli/commands/build.js | 72 ++++++++++++ src/cli/commands/help.js | 73 ++++++++++++ src/cli/commands/index.js | 5 + src/cli/commands/init.js | 29 +++++ src/cli/constants.js | 6 + src/cli/emoji.js | 9 ++ src/cli/main.js | 22 ++++ src/cli/utils.js | 107 ++++++++++++++++++ src/processTailwindFeatures.js | 25 ++-- 16 files changed, 396 insertions(+), 131 deletions(-) rename __tests__/fixtures/{customConfig.js => custom-config.js} (100%) delete mode 100644 __tests__/fixtures/tailwind-cli-input.css delete mode 100644 __tests__/fixtures/tailwind-cli-output.css create mode 100644 src/cli/commands/build.js create mode 100644 src/cli/commands/help.js create mode 100644 src/cli/commands/index.js create mode 100644 src/cli/commands/init.js create mode 100644 src/cli/constants.js create mode 100644 src/cli/emoji.js create mode 100644 src/cli/main.js create mode 100644 src/cli/utils.js diff --git a/__tests__/cli.test.js b/__tests__/cli.test.js index 19364ca08..7bf005ee4 100644 --- a/__tests__/cli.test.js +++ b/__tests__/cli.test.js @@ -1,21 +1,49 @@ -import { spawnSync } from 'child_process' -import fs from 'fs' import path from 'path' -function runCli(task, options) { - return spawnSync('node', [`${path.join(process.cwd(), 'lib/cli.js')}`, `${task}`, ...options]) -} +import cli from '../src/cli/main.js' +import constants from '../src/cli/constants.js' +import * as utils from '../src/cli/utils.js' -function pathToFixture(fixture) { - return path.resolve(`${__dirname}/fixtures/${fixture}`) -} +describe('cli', () => { + const inputCssPath = path.resolve(__dirname, 'fixtures/tailwind-input.css') + const customConfigPath = path.resolve(__dirname, 'fixtures/custom-config.js') -function readFixture(fixture) { - return fs.readFileSync(pathToFixture(fixture), 'utf8') -} + beforeEach(() => { + utils.log = jest.fn() + utils.writeFile = jest.fn() + }) -test('stdout only contains processed output', () => { - const expected = readFixture('tailwind-cli-output.css') - const result = runCli('build', [pathToFixture('tailwind-cli-input.css')]) - expect(result.stdout.toString()).toEqual(expected) + describe('init', () => { + it('creates a Tailwind config file', () => { + cli(['init']) + expect(utils.writeFile.mock.calls[0][0]).toEqual(constants.defaultConfigFile) + expect(utils.writeFile.mock.calls[0][1]).toContain('defaultConfig') + }) + + it('creates a Tailwind config file in a custom location', () => { + cli(['init', 'custom.js']) + expect(utils.writeFile.mock.calls[0][0]).toEqual('custom.js') + expect(utils.writeFile.mock.calls[0][1]).toContain('defaultConfig') + }) + }) + + describe('build', () => { + it('compiles CSS file', () => { + cli(['build', inputCssPath]) + expect(utils.writeFile.mock.calls[0][0]).toEqual(constants.defaultOutputFile) + expect(utils.writeFile.mock.calls[0][1]).toContain('.example') + }) + + it('compiles CSS file using custom configuration', () => { + cli(['build', inputCssPath, '--config', customConfigPath]) + expect(utils.writeFile.mock.calls[0][0]).toEqual(constants.defaultOutputFile) + expect(utils.writeFile.mock.calls[0][1]).toContain('400px') + }) + + it('creates compiled CSS file in a custom location', () => { + cli(['build', inputCssPath, '--output', 'custom.css']) + expect(utils.writeFile.mock.calls[0][0]).toEqual('custom.css') + expect(utils.writeFile.mock.calls[0][1]).toContain('.example') + }) + }) }) diff --git a/__tests__/customConfig.test.js b/__tests__/customConfig.test.js index f96ea9a5b..1c3c9a4f3 100644 --- a/__tests__/customConfig.test.js +++ b/__tests__/customConfig.test.js @@ -3,7 +3,7 @@ import postcss from 'postcss' import tailwind from '../src/index' test('it uses the values from the custom config file', () => { - return postcss([tailwind(path.resolve(`${__dirname}/fixtures/customConfig.js`))]) + return postcss([tailwind(path.resolve(`${__dirname}/fixtures/custom-config.js`))]) .process( ` @responsive { diff --git a/__tests__/fixtures/customConfig.js b/__tests__/fixtures/custom-config.js similarity index 100% rename from __tests__/fixtures/customConfig.js rename to __tests__/fixtures/custom-config.js diff --git a/__tests__/fixtures/tailwind-cli-input.css b/__tests__/fixtures/tailwind-cli-input.css deleted file mode 100644 index 563d20e99..000000000 --- a/__tests__/fixtures/tailwind-cli-input.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - color: green; -} diff --git a/__tests__/fixtures/tailwind-cli-output.css b/__tests__/fixtures/tailwind-cli-output.css deleted file mode 100644 index 563d20e99..000000000 --- a/__tests__/fixtures/tailwind-cli-output.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - color: green; -} diff --git a/package.json b/package.json index 07fa4a678..b75571b5a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "test": "jest && eslint . && nsp check" }, "devDependencies": { - "autoprefixer": "^7.1.6", "babel-cli": "^6.6.5", "babel-core": "^6.7.2", "babel-jest": "^20.0.3", @@ -43,16 +42,20 @@ "rimraf": "^2.6.1" }, "dependencies": { - "commander": "^2.11.0", + "autoprefixer": "^7.1.6", + "bytes": "^3.0.0", + "chalk": "^2.4.1", "css.escape": "^1.5.1", "fs-extra": "^4.0.2", "lodash": "^4.17.5", + "node-emoji": "^1.8.1", "perfectionist": "^2.4.0", "postcss": "^6.0.9", "postcss-functions": "^3.0.0", "postcss-js": "^1.0.1", "postcss-nested": "^3.0.0", - "postcss-selector-parser": "^3.1.1" + "postcss-selector-parser": "^3.1.1", + "pretty-hrtime": "^1.0.3" }, "browserslist": [ "> 1%" diff --git a/src/cli.js b/src/cli.js index 5433800b6..c127e8874 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,96 +1,16 @@ -#!/usr/bin/env node -/* eslint-disable no-process-exit */ +import main from './cli/main' +import { log, die } from './cli/utils' -import path from 'path' -import fs from 'fs-extra' -import tailwind from '..' -import postcss from 'postcss' -import process from 'process' -import program from 'commander' - -function writeStrategy(options) { - if (options.output === undefined) { - return output => { - process.stdout.write(output) - } - } - return output => { - fs.outputFileSync(options.output, output) +/** + * Runs the CLI application. + */ +function run() { + try { + main(process.argv.slice(2)) + log() + } catch (e) { + die(e.stack) } } -function buildTailwind(inputFile, config, write) { - console.warn('Building Tailwind!') - - const input = fs.readFileSync(inputFile, 'utf8') - - return postcss([tailwind(config)]) - .process(input, { from: inputFile }) - .then(result => { - write(result.css) - console.warn('Finished building Tailwind!') - }) - .catch(error => console.error(error)) -} - -const packageJson = require(path.resolve(__dirname, '../package.json')) - -program.version(packageJson.version).usage(' []') - -program - .command('init [filename]') - .usage('[options] [filename]') - .action((filename = 'tailwind.js') => { - let destination = path.resolve(filename) - - if (!path.extname(filename).includes('.js')) { - destination += '.js' - } - - if (fs.existsSync(destination)) { - console.error(`Destination ${destination} already exists, aborting.`) - process.exit(1) - } - - const output = fs.readFileSync(path.resolve(__dirname, '../defaultConfig.stub.js'), 'utf8') - fs.outputFileSync(destination, output.replace('// let defaultConfig', 'let defaultConfig')) - fs.outputFileSync( - destination, - output.replace("require('./plugins/container')", "require('tailwindcss/plugins/container')") - ) - console.warn(`Generated Tailwind config: ${destination}`) - process.exit() - }) - -program - .command('build') - .usage('[options] ') - .option('-c, --config [path]', 'Path to config file') - .option('-o, --output [path]', 'Output file') - .action((file, options) => { - let inputFile = program.args[0] - - if (!inputFile) { - console.error('No input file given!') - process.exit(1) - } - - buildTailwind(inputFile, options.config, writeStrategy(options)).then(() => { - process.exit() - }) - }) - -program - .command('*', null, { - noHelp: true, - }) - .action(() => { - program.help() - }) - -program.parse(process.argv) - -if (program.args.length === 0) { - program.help() - process.exit() -} +run() diff --git a/src/cli/commands/build.js b/src/cli/commands/build.js new file mode 100644 index 000000000..5636c6afb --- /dev/null +++ b/src/cli/commands/build.js @@ -0,0 +1,72 @@ +import autoprefixer from 'autoprefixer' +import bytes from 'bytes' +import chalk from 'chalk' +import postcss from 'postcss' +import prettyHrtime from 'pretty-hrtime' + +import commands from '.' +import constants from '../constants' +import emoji from '../emoji' +import tailwind from '../..' +import { error, exists, die, log, readFile, writeFile } from '../utils' + +export const usage = 'build [options]' +export const description = 'Compiles Tailwind CSS file.' + +export const options = [ + { + usage: '-c --config ', + description: 'Tailwind config file.', + }, + { + usage: '-o --output ', + description: 'Compiled CSS file. Default: ' + chalk.bold.magenta(constants.defaultOutputFile), + }, +] + +export const optionMap = { + config: ['c', 'config'], + output: ['o', 'output'], +} + +/** + * Runs the command. + * + * @param {string[]} cliParams + * @param {object} cliOptions + */ +export function run(cliParams, cliOptions) { + const time = process.hrtime() + const inputFile = cliParams[1] + const configFile = cliOptions.config && cliOptions.config[0] + const outputFile = (cliOptions.output && cliOptions.output[0]) || constants.defaultOutputFile + + if (!inputFile) { + error('CSS file is required.') + commands.help.forCommand(this) + die() + } + + !exists(inputFile) && die(chalk.bold.magenta(inputFile), 'does not exist.') + configFile && !exists(configFile) && die(chalk.bold.magenta(configFile), 'does not exist.') + + log() + log(emoji.go, 'Building', chalk.bold.cyan(inputFile)) + + const css = readFile(inputFile) + const plugins = [tailwind(configFile), autoprefixer] + + const result = postcss(plugins).process(css, { + from: inputFile, + to: outputFile, + }) + + writeFile(outputFile, result.css) + + const prettyTime = prettyHrtime(process.hrtime(time)) + + log() + log(emoji.yes, 'Finished in', chalk.bold.magenta(prettyTime)) + log(emoji.pack, 'Size:', chalk.bold.magenta(bytes(result.css.length))) + log(emoji.disk, 'Saved to', chalk.bold.cyan(outputFile)) +} diff --git a/src/cli/commands/help.js b/src/cli/commands/help.js new file mode 100644 index 000000000..d56ab1352 --- /dev/null +++ b/src/cli/commands/help.js @@ -0,0 +1,73 @@ +import chalk from 'chalk' +import { forEach, map } from 'lodash' + +import commands from '.' +import constants from '../constants' +import { error, die, log } from '../utils' + +export const usage = 'help [command]' +export const description = 'More information about the command.' + +/** + * Prints general help. + */ +export function forApp() { + const pad = Math.max(...map(commands, 'usage.length')) + 3 + + log() + log('Usage:') + log(' ', chalk.bold(constants.cli + ' [options]')) + log() + log('Commands:') + forEach(commands, command => { + log(' ', chalk.bold(command.usage.padEnd(pad)), command.description) + }) +} + +/** + * Prints help for a command. + * + * @param {object} command + */ +export function forCommand(command) { + log() + log('Usage:') + log(' ', chalk.bold(constants.cli, command.usage)) + log() + log('Description:') + log(' ', chalk.bold(command.description)) + + if (command.options) { + const pad = Math.max(...map(command.options, 'usage.length')) + 3 + + log() + log('Options:') + forEach(command.options, option => { + log(' ', chalk.bold(option.usage.padEnd(pad)), option.description) + }) + } +} + +/** + * Prints invalid command error and general help. Kills the process. + * + * @param {string} commandName + */ +export function invalidCommand(commandName) { + error('Invalid command:', chalk.bold.magenta(commandName)) + forApp() + die() +} + +/** + * Runs the command. + * + * @param {string[]} cliParams + */ +export function run(cliParams) { + const command = cliParams[1] + + !command && forApp() + command && commands[command] && forCommand(commands[command]) + command && !commands[command] && invalidCommand(command) +} diff --git a/src/cli/commands/index.js b/src/cli/commands/index.js new file mode 100644 index 000000000..180121917 --- /dev/null +++ b/src/cli/commands/index.js @@ -0,0 +1,5 @@ +import * as help from './help' +import * as init from './init' +import * as build from './build' + +export default { help, init, build } diff --git a/src/cli/commands/init.js b/src/cli/commands/init.js new file mode 100644 index 000000000..b4f8be305 --- /dev/null +++ b/src/cli/commands/init.js @@ -0,0 +1,29 @@ +import chalk from 'chalk' + +import constants from '../constants' +import emoji from '../emoji' +import { exists, die, log, readFile, writeFile } from '../utils' + +export const usage = 'init [file]' +export const description = + 'Creates Tailwind config file. Default: ' + chalk.bold.magenta(constants.defaultConfigFile) + +/** + * Runs the command. + * + * @param {string[]} cliParams + */ +export function run(cliParams) { + const file = cliParams[1] || constants.defaultConfigFile + + exists(file) && die(chalk.bold.magenta(file), 'already exists.') + + let stub = readFile(constants.configStubFile) + stub = stub.replace('// let defaultConfig', 'let defaultConfig') + stub = stub.replace("require('./plugins/container')", "require('tailwindcss/plugins/container')") + + writeFile(file, stub) + + log() + log(emoji.yes, 'Created Tailwind config file:', chalk.bold.magenta(file)) +} diff --git a/src/cli/constants.js b/src/cli/constants.js new file mode 100644 index 000000000..4de42bf35 --- /dev/null +++ b/src/cli/constants.js @@ -0,0 +1,6 @@ +export default { + cli: 'yarn tailwind', + defaultConfigFile: 'tailwind.js', + defaultOutputFile: 'output.css', + configStubFile: 'defaultConfig.stub.js', +} diff --git a/src/cli/emoji.js b/src/cli/emoji.js new file mode 100644 index 000000000..f6c8b028c --- /dev/null +++ b/src/cli/emoji.js @@ -0,0 +1,9 @@ +import { get } from 'node-emoji' + +export default { + yes: get('white_check_mark'), + no: get('no_entry_sign'), + go: get('rocket'), + pack: get('package'), + disk: get('floppy_disk'), +} diff --git a/src/cli/main.js b/src/cli/main.js new file mode 100644 index 000000000..4f347e304 --- /dev/null +++ b/src/cli/main.js @@ -0,0 +1,22 @@ +import chalk from 'chalk' + +import commands from './commands' +import packageJson from '../../package.json' +import { log, parseCliOptions, parseCliParams } from './utils' + +/** + * CLI application entrypoint. + */ +export default function run(args) { + log() + log(chalk.bold(packageJson.name), chalk.bold.cyan(packageJson.version)) + + const params = parseCliParams(args) + const commandName = params[0] || 'help' + + !commands[commandName] && commands.help.invalidCommand(commandName) + + const options = parseCliOptions(args, commands[commandName].optionMap) + + commands[commandName].run(params, options) +} diff --git a/src/cli/utils.js b/src/cli/utils.js new file mode 100644 index 000000000..fb4d2e6da --- /dev/null +++ b/src/cli/utils.js @@ -0,0 +1,107 @@ +import chalk from 'chalk' +import { ensureFileSync, existsSync, outputFileSync, readFileSync } from 'fs-extra' +import { findKey, mapValues, trimStart } from 'lodash' + +import emoji from './emoji' + +/** + * Gets CLI parameters. + * + * @param {string[]} args CLI arguments + * @return {string[]} + */ +export function parseCliParams(args) { + const firstOptionIndex = args.findIndex(arg => arg.startsWith('-')) + + return firstOptionIndex > -1 ? args.slice(0, firstOptionIndex) : args +} + +/** + * Gets mapped CLI options. + * + * @param {string[]} args CLI arguments + * @param {object} [optionMap] + * @return {object} + */ +export function parseCliOptions(args, optionMap = {}) { + let options = {} + let currentOption = [] + + args.forEach(arg => { + const option = arg.startsWith('-') && trimStart(arg, '-').toLowerCase() + const resolvedOption = findKey(optionMap, aliases => aliases.includes(option)) + + if (resolvedOption) { + currentOption = options[resolvedOption] || (options[resolvedOption] = []) + } else if (option) { + currentOption = [] + } else { + currentOption.push(arg) + } + }) + + return { ...mapValues(optionMap, () => undefined), ...options } +} + +/** + * Prints messages to console. + * + * @param {...string} msgs + */ +export function log(...msgs) { + console.log(' ', ...msgs) +} + +/** + * Prints error messages to console. + * + * @param {...string} msgs + */ +export function error(...msgs) { + log() + console.error(' ', emoji.no, chalk.bold.red(msgs.join(' '))) +} + +/** + * Kills the process. Optionally prints error messages to console. + * + * @param {...string} [msgs] + */ +export function die(...msgs) { + msgs.length && error(...msgs) + log() + process.exit(1) // eslint-disable-line +} + +/** + * Checks if path exists. + * + * @param {string} path + * @return {boolean} + */ +export function exists(path) { + return existsSync(path) +} + +/** + * Gets file content. + * + * @param {string} path + * @return {string} + */ +export function readFile(path) { + return readFileSync(path, 'utf-8') +} + +/** + * Writes content to file. + * + * @param {string} path + * @param {string} content + * @return {string} + */ +export function writeFile(path, content) { + ensureFileSync(path) + + return outputFileSync(path, content) +} diff --git a/src/processTailwindFeatures.js b/src/processTailwindFeatures.js index 51595f6f0..2726d58c5 100644 --- a/src/processTailwindFeatures.js +++ b/src/processTailwindFeatures.js @@ -1,4 +1,3 @@ -import _ from 'lodash' import postcss from 'postcss' import substituteTailwindAtRules from './lib/substituteTailwindAtRules' @@ -12,18 +11,16 @@ import generateUtilities from './util/generateUtilities' import processPlugins from './util/processPlugins' export default function(getConfig) { - return function(css) { - const config = getConfig() - const processedPlugins = processPlugins(config) - const utilities = generateUtilities(config, processedPlugins.utilities) + const config = getConfig() + const processedPlugins = processPlugins(config) + const utilities = generateUtilities(config, processedPlugins.utilities) - return postcss([ - substituteTailwindAtRules(config, processedPlugins, utilities), - evaluateTailwindFunctions(config), - substituteVariantsAtRules(config, processedPlugins), - substituteResponsiveAtRules(config), - substituteScreenAtRules(config), - substituteClassApplyAtRules(config, utilities), - ]).process(css, { from: _.get(css, 'source.input.file') }) - } + return postcss([ + substituteTailwindAtRules(config, processedPlugins, utilities), + evaluateTailwindFunctions(config), + substituteVariantsAtRules(config, processedPlugins), + substituteResponsiveAtRules(config), + substituteScreenAtRules(config), + substituteClassApplyAtRules(config, utilities), + ]) }