diff --git a/__tests__/cli.test.js b/__tests__/cli.test.js index 19364ca08a80..5dcbcf69a465 100644 --- a/__tests__/cli.test.js +++ b/__tests__/cli.test.js @@ -1,21 +1,53 @@ -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' +import * as constants from '../src/cli/constants' +import * as utils from '../src/cli/utils' -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(() => { + console.log = jest.fn() + process.stdout.write = 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']).then(() => { + 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']).then(() => { + 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]).then(() => { + expect(process.stdout.write.mock.calls[0][0]).toContain('.example') + }) + }) + + it('compiles CSS file using custom configuration', () => { + cli(['build', inputCssPath, '--config', customConfigPath]).then(() => { + expect(process.stdout.write.mock.calls[0][0]).toContain('400px') + }) + }) + + it('creates compiled CSS file', () => { + cli(['build', inputCssPath, '--output', 'output.css']).then(() => { + expect(utils.writeFile.mock.calls[0][0]).toEqual('output.css') + expect(utils.writeFile.mock.calls[0][1]).toContain('.example') + }) + }) + }) }) diff --git a/__tests__/customConfig.test.js b/__tests__/customConfig.test.js index f96ea9a5b4aa..1c3c9a4f39f4 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 563d20e99ce8..000000000000 --- 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 563d20e99ce8..000000000000 --- 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 07fa4a67812e..b75571b5a000 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 5433800b6360..312ff6c26895 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,96 +1,6 @@ #!/usr/bin/env node -/* eslint-disable no-process-exit */ -import path from 'path' -import fs from 'fs-extra' -import tailwind from '..' -import postcss from 'postcss' -import process from 'process' -import program from 'commander' +import main from './cli/main' +import * as utils from './cli/utils' -function writeStrategy(options) { - if (options.output === undefined) { - return output => { - process.stdout.write(output) - } - } - return output => { - fs.outputFileSync(options.output, output) - } -} - -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() -} +main(process.argv.slice(2)).catch(error => utils.die(error.stack)) diff --git a/src/cli/commands/build.js b/src/cli/commands/build.js new file mode 100644 index 000000000000..ab3939854deb --- /dev/null +++ b/src/cli/commands/build.js @@ -0,0 +1,143 @@ +import autoprefixer from 'autoprefixer' +import bytes from 'bytes' +import chalk from 'chalk' +import postcss from 'postcss' +import prettyHrtime from 'pretty-hrtime' + +import tailwind from '../..' + +import commands from '.' +import * as emoji from '../emoji' +import * as utils from '../utils' + +export const usage = 'build [options]' +export const description = 'Compiles Tailwind CSS file.' + +export const options = [ + { + usage: '-o, --output ', + description: 'Output file.', + }, + { + usage: '-c, --config ', + description: 'Tailwind config file.', + }, +] + +export const optionMap = { + output: ['output', 'o'], + config: ['config', 'c'], +} + +/** + * Prints the error message and stops the process. + * + * @param {...string} [msgs] + */ +function stop(...msgs) { + utils.header() + utils.error(...msgs) + utils.die() +} + +/** + * Prints the error message and help for this command, then stops the process. + * + * @param {...string} [msgs] + */ +function stopWithHelp(...msgs) { + utils.header() + utils.error(...msgs) + commands.help.forCommand(commands.build) + utils.die() +} + +/** + * Compiles CSS file. + * + * @param {string} inputFile + * @param {string} configFile + * @param {string} outputFile + * @return {Promise} + */ +function build(inputFile, configFile, outputFile) { + const css = utils.readFile(inputFile) + + return new Promise((resolve, reject) => { + postcss([tailwind(configFile), autoprefixer]) + .process(css, { + from: inputFile, + to: outputFile, + }) + .then(resolve) + .catch(reject) + }) +} + +/** + * Compiles CSS file and writes it to stdout. + * + * @param {string} inputFile + * @param {string} configFile + * @param {string} outputFile + * @return {Promise} + */ +function buildToStdout(inputFile, configFile, outputFile) { + return build(inputFile, configFile, outputFile).then(result => process.stdout.write(result.css)) +} + +/** + * Compiles CSS file and writes it to a file. + * + * @param {string} inputFile + * @param {string} configFile + * @param {string} outputFile + * @param {int[]} startTime + * @return {Promise} + */ +function buildToFile(inputFile, configFile, outputFile, startTime) { + utils.header() + utils.log() + utils.log(emoji.go, 'Building...', chalk.bold.cyan(inputFile)) + + return build(inputFile, configFile, outputFile).then(result => { + utils.writeFile(outputFile, result.css) + + const prettyTime = prettyHrtime(process.hrtime(startTime)) + + utils.log() + utils.log(emoji.yes, 'Finished in', chalk.bold.magenta(prettyTime)) + utils.log(emoji.pack, 'Size:', chalk.bold.magenta(bytes(result.css.length))) + utils.log(emoji.disk, 'Saved to', chalk.bold.cyan(outputFile)) + utils.footer() + }) +} + +/** + * Runs the command. + * + * @param {string[]} cliParams + * @param {object} cliOptions + * @return {Promise} + */ +export function run(cliParams, cliOptions) { + return new Promise((resolve, reject) => { + const startTime = process.hrtime() + const inputFile = cliParams[0] + const configFile = cliOptions.config && cliOptions.config[0] + const outputFile = cliOptions.output && cliOptions.output[0] + + !inputFile && stopWithHelp('CSS file is required.') + !utils.exists(inputFile) && stop(chalk.bold.magenta(inputFile), 'does not exist.') + + configFile && + !utils.exists(configFile) && + stop(chalk.bold.magenta(configFile), 'does not exist.') + + const buildPromise = outputFile + ? buildToFile(inputFile, configFile, outputFile, startTime) + : buildToStdout(inputFile, configFile, outputFile) + + buildPromise.then(resolve).catch(reject) + }) +} diff --git a/src/cli/commands/help.js b/src/cli/commands/help.js new file mode 100644 index 000000000000..a0803145694b --- /dev/null +++ b/src/cli/commands/help.js @@ -0,0 +1,85 @@ +import chalk from 'chalk' +import { forEach, map, padEnd } from 'lodash' + +import commands from '.' +import * as constants from '../constants' +import * as utils from '../utils' + +export const usage = 'help [command]' +export const description = 'More information about the command.' + +const PADDING_SIZE = 3 + +/** + * Prints general help. + */ +export function forApp() { + const pad = Math.max(...map(commands, 'usage.length')) + PADDING_SIZE + + utils.log() + utils.log('Usage:') + utils.log(' ', chalk.bold(constants.cli + ' [options]')) + utils.log() + utils.log('Commands:') + forEach(commands, command => { + utils.log(' ', chalk.bold(padEnd(command.usage, pad)), command.description) + }) +} + +/** + * Prints help for a command. + * + * @param {object} command + */ +export function forCommand(command) { + utils.log() + utils.log('Usage:') + utils.log(' ', chalk.bold(constants.cli, command.usage)) + utils.log() + utils.log('Description:') + utils.log(' ', chalk.bold(command.description)) + + if (command.options) { + const pad = Math.max(...map(command.options, 'usage.length')) + PADDING_SIZE + + utils.log() + utils.log('Options:') + forEach(command.options, option => { + utils.log(' ', chalk.bold(padEnd(option.usage, pad)), option.description) + }) + } +} + +/** + * Prints invalid command error and general help. Kills the process. + * + * @param {string} commandName + */ +export function invalidCommand(commandName) { + utils.error('Invalid command:', chalk.bold.magenta(commandName)) + forApp() + utils.die() +} + +/** + * Runs the command. + * + * @param {string[]} cliParams + * @return {Promise} + */ +export function run(cliParams) { + return new Promise(resolve => { + utils.header() + + const commandName = cliParams[0] + const command = commands[commandName] + + !commandName && forApp() + commandName && command && forCommand(command) + commandName && !command && invalidCommand(commandName) + + utils.footer() + + resolve() + }) +} diff --git a/src/cli/commands/index.js b/src/cli/commands/index.js new file mode 100644 index 000000000000..180121917cac --- /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 000000000000..1d127240c337 --- /dev/null +++ b/src/cli/commands/init.js @@ -0,0 +1,39 @@ +import chalk from 'chalk' + +import * as constants from '../constants' +import * as emoji from '../emoji' +import * as utils 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 + * @return {Promise} + */ +export function run(cliParams) { + return new Promise(resolve => { + utils.header() + + const file = cliParams[0] || constants.defaultConfigFile + + utils.exists(file) && utils.die(chalk.bold.magenta(file), 'already exists.') + + const stub = utils + .readFile(constants.configStubFile) + .replace('// let defaultConfig', 'let defaultConfig') + .replace("require('./plugins/container')", "require('tailwindcss/plugins/container')") + + utils.writeFile(file, stub) + + utils.log() + utils.log(emoji.yes, 'Created Tailwind config file:', chalk.bold.magenta(file)) + + utils.footer() + + resolve() + }) +} diff --git a/src/cli/constants.js b/src/cli/constants.js new file mode 100644 index 000000000000..3e9fdb42da4c --- /dev/null +++ b/src/cli/constants.js @@ -0,0 +1,5 @@ +import path from 'path' + +export const cli = 'tailwind' +export const defaultConfigFile = 'tailwind.js' +export const configStubFile = path.resolve(__dirname, '../../defaultConfig.stub.js') diff --git a/src/cli/emoji.js b/src/cli/emoji.js new file mode 100644 index 000000000000..58655706672b --- /dev/null +++ b/src/cli/emoji.js @@ -0,0 +1,7 @@ +import { get } from 'node-emoji' + +export const yes = get('white_check_mark') +export const no = get('no_entry_sign') +export const go = get('rocket') +export const pack = get('package') +export const disk = get('floppy_disk') diff --git a/src/cli/main.js b/src/cli/main.js new file mode 100644 index 000000000000..06d2947a409a --- /dev/null +++ b/src/cli/main.js @@ -0,0 +1,22 @@ +import commands from './commands' +import * as utils from './utils' + +/** + * CLI application entrypoint. + * + * @param {string[]} cliArgs + * @return {Promise} + */ +export default function run(cliArgs) { + return new Promise((resolve, reject) => { + const params = utils.parseCliParams(cliArgs) + const command = commands[params[0]] + const options = command ? utils.parseCliOptions(cliArgs, command.optionMap) : {} + + const commandPromise = command + ? command.run(params.slice(1), options) + : commands.help.run(params) + + commandPromise.then(resolve).catch(reject) + }) +} diff --git a/src/cli/utils.js b/src/cli/utils.js new file mode 100644 index 000000000000..ac138b854c25 --- /dev/null +++ b/src/cli/utils.js @@ -0,0 +1,123 @@ +import chalk from 'chalk' +import { ensureFileSync, existsSync, outputFileSync, readFileSync } from 'fs-extra' +import { findKey, mapValues, trimStart } from 'lodash' + +import * as emoji from './emoji' +import packageJson from '../../package.json' + +/** + * Gets CLI parameters. + * + * @param {string[]} cliArgs + * @return {string[]} + */ +export function parseCliParams(cliArgs) { + const firstOptionIndex = cliArgs.findIndex(cliArg => cliArg.startsWith('-')) + + return firstOptionIndex > -1 ? cliArgs.slice(0, firstOptionIndex) : cliArgs +} + +/** + * Gets mapped CLI options. + * + * @param {string[]} cliArgs + * @param {object} [optionMap] + * @return {object} + */ +export function parseCliOptions(cliArgs, optionMap = {}) { + let options = {} + let currentOption = [] + + cliArgs.forEach(cliArg => { + const option = cliArg.startsWith('-') && trimStart(cliArg, '-').toLowerCase() + const resolvedOption = findKey(optionMap, aliases => aliases.includes(option)) + + if (resolvedOption) { + currentOption = options[resolvedOption] || (options[resolvedOption] = []) + } else if (option) { + currentOption = [] + } else { + currentOption.push(cliArg) + } + }) + + return { ...mapValues(optionMap, () => undefined), ...options } +} + +/** + * Prints messages to console. + * + * @param {...string} [msgs] + */ +export function log(...msgs) { + console.log(' ', ...msgs) +} + +/** + * Prints application header to console. + */ +export function header() { + log() + log(chalk.bold(packageJson.name), chalk.bold.cyan(packageJson.version)) +} + +/** + * Prints application footer to console. + */ +export function footer() { + log() +} + +/** + * 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) + footer() + 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) +}