diff --git a/package-lock.json b/package-lock.json index 67d6c7cd..f7388226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "culori", "version": "4.0.2", "license": "MIT", + "bin": { + "culori": "src/cli.js" + }, "devDependencies": { "@11ty/eleventy": "^3.1.2", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", diff --git a/package.json b/package.json index 238497e8..211f29fd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "./fn": "./src/index-fn.js", "./package.json": "./package.json" }, + "bin": { + "culori": "./src/cli.js" + }, "repository": "git@github.com:Evercoder/culori.git", "author": "Dan Burzo ", "description": "A general-purpose color library for JavaScript", diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 00000000..bb530356 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import * as cul from './index.js'; + +const VERSION = '1.0.0'; + +/** + * This is all the modes, but also 'hex', because 'hex' and 'rgb' are + * both parsed into 'rgb' internally. + * @typedef {'hex' | 'rgb' | 'hsl' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'xyz' | 'xyy' | 'hsv' | 'hcg' | 'hsi' | 'hcy' | 'cmyk'} Format + */ + +/** + * Formats into a string. + * + * @param {Object} color - The color object to format. + * @param {Format} [format] - The format mode. If not provided, uses the color's mode. + * @returns {string} The formatted color string. + */ +function format(color, format) { + if (format === undefined) { + format = color.mode; + } + switch (format) { + case 'hex': + return cul.formatHex8(color); + case 'rgb': + return cul.formatRgb(color); + case 'hsl': + return cul.formatHsl(color); + default: + const converter = cul.converter(format); + const converted = converter(color); + return cul.formatCss(converted); + } +} + +function parseWithFormat(colorStr) { + const color = cul.parse(colorStr); + let format = color ? color.mode : null; + if (format === 'rgb' && colorStr.includes('#')) { + format = 'hex'; + } + return { color, format }; +} + +export async function main({ + args: rawArgs = process.argv, + console: con = console +} = {}) { + const binString = rawArgs.slice(0, 2).join(' '); + const args = rawArgs.slice(2); + const HELP = ` +usage: ${binString} [options] +Commands: + convert --to Convert color to a different format + brighten Adjust brightness of a color using CSS brightness() filter + --amount An amount of 1 leaves the color unchanged. + [--to ] Smaller values darken the color (with 0 being fully black), while larger values brighten it. + blend Blend two colors + [--mode ] + [--to ] + info Show info about color + luminance Show luminance based on WCAG + contrast Show contrast ratio based on WCAG + help Show help + version Show version + +Supported formats: hex, rgb, hsl, lab, lch, oklch, oklab, etc. +Supported blend modes: multiply, screen, overlay, etc. +`.trim(); + + if (args.length === 0) { + con.error(HELP); + return 1; + } + if (args[0] === 'help' || args.includes('--help')) { + con.log(HELP); + return 0; + } + if (args[0] === 'version' || args.includes('--version')) { + con.log(VERSION); + return 0; + } + + const cmd = args[0]; + const VALID_COMMANDS = [ + 'convert', + 'brighten', + 'blend', + 'info', + 'luminance', + 'contrast' + ]; + if (!VALID_COMMANDS.includes(cmd)) { + con.error('Unknown command:', cmd); + con.error(`Use "${binString} help" to see available commands.`); + return 1; + } + + const inputColor = args[1]; + if (!inputColor) { + con.error('No color input provided.'); + return 1; + } + const { color, format: inputFormat } = parseWithFormat(inputColor); + if (!color) { + con.error('Invalid color input.'); + return 1; + } + + const NO_VALUE = Symbol('no value'); + function getOption(optionName, defaultValue = NO_VALUE) { + const idx = args.indexOf(optionName); + if (idx === -1) { + return defaultValue; + } + return args[idx + 1]; + } + + const to = getOption('--to'); + const outFormat = to !== NO_VALUE ? to : inputFormat; + + if (cmd === 'convert') { + if (to === NO_VALUE) { + con.error(`usage: ${binString} convert --to `); + return 1; + } + con.log(format(color, outFormat)); + return 0; + } + if (cmd === 'brighten') { + const amountString = getOption('--amount'); + if (amountString === NO_VALUE) { + con.error( + `usage: ${binString} brighten --amount [--to ]` + ); + return 1; + } + const amount = parseFloat(amountString); + const filter = cul.filterBrightness(amount, 'rgb'); + con.log(format(filter(color), outFormat)); + return 0; + } + if (cmd === 'blend') { + const color2 = args[2] || ''; + const mode = getOption('--mode', 'multiply'); + const c2 = cul.parse(color2); + if (!c2) { + con.error('Invalid second color for blend.'); + return 1; + } + con.log(format(cul.blend([color, c2], mode), outFormat)); + return 0; + } + if (cmd === 'info') { + con.log(`HEX: ${format(color, 'hex')}`); + con.log(`RGB: ${format(color, 'rgb')}`); + con.log(`HSL: ${format(color, 'hsl')}`); + con.log(`OKLCH: ${format(color, 'oklch')}`); + con.log(`Luminance: ${cul.wcagLuminance(color).toFixed(3)}`); + con.log(`Is valid: Yes`); + return 0; + } + if (cmd === 'luminance') { + con.log(cul.wcagLuminance(color)); + return 0; + } + if (cmd === 'contrast') { + const color2 = args[2] || ''; + const c2 = cul.parse(color2); + if (!c2) { + con.error('Invalid second color for contrast.'); + return 1; + } + con.log(cul.wcagContrast(color, c2)); + return 0; + } + con.error('Unknown command:', cmd); + con.error(`Use "${binString} help" to see available commands.`); + return 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + const exitCode = await main(); + process.exit(exitCode || 0); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 00000000..eaaf4d44 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,168 @@ +import assert from 'node:assert'; +import test from 'node:test'; + +import { main } from '../src/cli.js'; + +function createMockConsole() { + const logs = []; + const errors = []; + return { + log: msg => logs.push(msg), + error: msg => errors.push(msg), + getLogs: () => logs, + getErrors: () => errors + }; +} + +async function run(args) { + const consoleMock = createMockConsole(); + const exitCode = await main({ + args: ['node', 'cli.js', ...args], + console: consoleMock + }); + return { + logs: consoleMock.getLogs(), + errors: consoleMock.getErrors(), + exitCode + }; +} + +async function runsSuccessfully(args, expectedLogs) { + const { logs, errors, exitCode } = await run(args); + if (exitCode !== 0) { + throw new Error( + `Expected exit code 0 but got ${exitCode}. Errors: ${errors.join('\n')}` + ); + } + if (typeof expectedLogs !== 'undefined') { + assertEquals(logs, expectedLogs); + } + return logs; +} + +async function runsWithError(args, expectedErrors) { + const { logs, errors, exitCode } = await run(args); + if (exitCode === 0) { + throw new Error( + `Expected non-zero exit code but got ${exitCode}. Logs: ${logs.join('\n')}` + ); + } + if (typeof expectedErrors !== 'undefined') { + assertEquals(errors, expectedErrors); + } + return errors; +} + +function assertEquals(actual, expected) { + const expectedArray = Array.isArray(expected) ? expected : [expected]; + const minLength = Math.min(actual.length, expectedArray.length); + for (let i = 0; i < minLength; i++) { + if (expectedArray[i] instanceof RegExp) { + assert.match(actual[i], expectedArray[i]); + } else { + assert.strictEqual(actual[i], expectedArray[i]); + } + } + if (actual.length !== expectedArray.length) { + throw new Error( + `Expected ${expectedArray.length} entries but got ${actual.length}. Actual: ${actual.join('\n')}` + ); + } +} + +test('convert', async () => { + await runsSuccessfully( + ['convert', '#ff9933', '--to', 'rgb'], + 'rgb(255, 153, 51)' + ); + await runsSuccessfully(['convert', '#ff9933', '--to', 'hex'], '#ff9933ff'); + await runsSuccessfully( + ['convert', '#ff9933', '--to', 'oklch'], + /^oklch\(0\.77\d+ 0\.16\d+ 60\.\d+\)$/ + ); +}); + +test('brighten', async () => { + await runsSuccessfully( + ['brighten', '#ff9933', '--amount', '2'], + '#ffff66ff' + ); + await runsSuccessfully( + ['brighten', '#ff9933', '--amount', '0.5', '--to', 'rgb'], + 'rgb(128, 77, 26)' + ); + const { errors, exitCode } = await run(['brighten', '#ff9933']); + assert.strictEqual(exitCode, 1); + assert.match( + errors[0], + /usage: .* brighten --amount \[--to \]/ + ); +}); + +test('blend', async () => { + await runsSuccessfully( + ['blend', '#ff9933', 'rgb(128, 77, 26)'], + '#802e05ff' + ); + await runsSuccessfully( + ['blend', '#ff9933', 'rgb(128, 77, 26)', '--mode', 'screen'], + '#ffb848ff' + ); +}); + +test('info', async () => { + await runsSuccessfully( + ['info', '#ff9933'], + [ + 'HEX: #ff9933ff', + 'RGB: rgb(255, 153, 51)', + 'HSL: hsl(30, 100%, 60%)', + /^OKLCH: oklch\(0\.77\d+ 0\.16\d+ 60\.\d+\)$/, + 'Luminance: 0.443', + 'Is valid: Yes' + ] + ); +}); + +test('luminance', async () => { + const [raw] = await runsSuccessfully(['luminance', '#ff9933']); + const parsed = parseFloat(raw); + assert.ok(parsed > 0.442 && parsed < 0.444); +}); + +test('contrast', async () => { + const [raw] = await runsSuccessfully(['contrast', '#ff9933', '#3399ff']); + const parsed = parseFloat(raw); + assert.ok(parsed > 1.37 && parsed < 1.39); +}); + +test('invalid color', async () => { + await runsWithError( + ['convert', 'notacolor', '--to', 'rgb'], + /Invalid color/ + ); +}); + +test('unknown command', async () => { + const expected = [ + /Unknown command/, + /Use "node cli.js help" to see available commands./ + ]; + await runsWithError(['foobar'], expected); + await runsWithError(['foobar', '#ff9933'], expected); +}); + +test('version', async () => { + await runsSuccessfully(['version'], /\d+\.\d+\.\d+/); + await runsSuccessfully(['convert', 'foobar', '--version'], /\d+\.\d+\.\d+/); +}); + +test('no args', async () => { + await runsWithError([], /usage:/); +}); + +test('help', async () => { + await runsSuccessfully(['help'], /usage:/); + await runsSuccessfully(['--help'], /usage:/); + await runsSuccessfully(['convert', '--help'], /usage:/); +});