Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dan@danburzo.ro>",
"description": "A general-purpose color library for JavaScript",
Expand Down
191 changes: 191 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -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} <command> [options]
Commands:
convert <color> --to <format> Convert color to a different format
brighten <color> Adjust brightness of a color using CSS brightness() filter
--amount <float> An amount of 1 leaves the color unchanged.
[--to <format>] Smaller values darken the color (with 0 being fully black), while larger values brighten it.
blend <color1> <color2> Blend two colors
[--mode <mode='multiply'>]
[--to <format>]
info <color> Show info about color
luminance <color> Show luminance based on WCAG
contrast <color1> <color2> 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 <color> --to <format>`);
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 <color> --amount <float> [--to <format>]`
);
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);
}
}
168 changes: 168 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
@@ -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 <color> --amount <float> \[--to <format>\]/
);
});

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:/);
});