CLI tool rewrite

This commit is contained in:
Matt Stypa 2018-09-13 21:37:10 -05:00
parent a63c976dc7
commit b71cfe711e
16 changed files with 396 additions and 131 deletions

View file

@ -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')
})
})
})

View file

@ -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 {

View file

@ -1,3 +0,0 @@
body {
color: green;
}

View file

@ -1,3 +0,0 @@
body {
color: green;
}

View file

@ -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%"

View file

@ -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('<command> [<args>]')
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] <file ...>')
.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()

72
src/cli/commands/build.js Normal file
View file

@ -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 <file> [options]'
export const description = 'Compiles Tailwind CSS file.'
export const options = [
{
usage: '-c --config <file>',
description: 'Tailwind config file.',
},
{
usage: '-o --output <file>',
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))
}

73
src/cli/commands/help.js Normal file
View file

@ -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 + ' <command> [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)
}

View file

@ -0,0 +1,5 @@
import * as help from './help'
import * as init from './init'
import * as build from './build'
export default { help, init, build }

29
src/cli/commands/init.js Normal file
View file

@ -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))
}

6
src/cli/constants.js Normal file
View file

@ -0,0 +1,6 @@
export default {
cli: 'yarn tailwind',
defaultConfigFile: 'tailwind.js',
defaultOutputFile: 'output.css',
configStubFile: 'defaultConfig.stub.js',
}

9
src/cli/emoji.js Normal file
View file

@ -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'),
}

22
src/cli/main.js Normal file
View file

@ -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)
}

107
src/cli/utils.js Normal file
View file

@ -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)
}

View file

@ -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),
])
}