diff --git a/package.json b/package.json index 63dab1a0c..46e157e5d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "chalk": "^4.1.2", "clean-stack": "^3.0.1", "cli-progress": "^3.12.0", + "color": "^4.2.3", "debug": "^4.3.4", "ejs": "^3.1.9", "get-package-type": "^0.1.0", @@ -44,6 +45,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/clean-stack": "^2.1.1", "@types/cli-progress": "^3.11.0", + "@types/color": "^3.0.5", "@types/debug": "^4.1.10", "@types/ejs": "^3.1.3", "@types/indent-string": "^4.0.1", @@ -107,6 +109,7 @@ "access": "public" }, "scripts": { + "build:dev": "shx rm -rf lib && tsc --sourceMap", "build": "shx rm -rf lib && tsc", "commitlint": "commitlint", "compile": "tsc", @@ -117,9 +120,11 @@ "prepare": "husky install", "pretest": "yarn build && tsc -p test --noEmit --skipLibCheck", "test:circular-deps": "madge lib/ -c", + "test:debug": "nyc mocha --debug-brk --inspect \"test/**/*.test.ts\"", "test:e2e": "mocha --forbid-only \"test/**/*.e2e.ts\" --parallel --timeout 1200000", "test:esm-cjs": "cross-env DEBUG=e2e:* ts-node test/integration/esm-cjs.ts", "test:perf": "ts-node test/perf/parser.perf.ts", + "test:dev": "nyc mocha \"test/**/*.test.ts\"", "test": "nyc mocha --forbid-only \"test/**/*.test.ts\"" }, "types": "lib/index.d.ts" diff --git a/src/config/config.ts b/src/config/config.ts index 8ef88ac70..9eed4ec40 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,7 +1,7 @@ import * as ejs from 'ejs' import WSL from 'is-wsl' import {arch, userInfo as osUserInfo, release, tmpdir, type} from 'node:os' -import {join, sep} from 'node:path' +import {join, resolve, sep} from 'node:path' import {URL, fileURLToPath} from 'node:url' import {ux} from '../cli-ux' @@ -9,14 +9,14 @@ import {Command} from '../command' import {CLIError, error, exit, warn} from '../errors' import {getHelpFlagAdditions} from '../help/util' import {Hook, Hooks, PJSON, Topic} from '../interfaces' -import {ArchTypes, Config as IConfig, LoadOptions, PlatformTypes, VersionDetails} from '../interfaces/config' +import {ArchTypes, Config as IConfig, LoadOptions, PlatformTypes, Theme, VersionDetails} from '../interfaces/config' import {Plugin as IPlugin, Options} from '../interfaces/plugin' import {loadWithData} from '../module-loader' import {OCLIF_MARKER_OWNER, Performance} from '../performance' import {settings} from '../settings' -import {requireJson} from '../util/fs' +import {requireJson, safeReadJson} from '../util/fs' import {getHomeDir, getPlatform} from '../util/os' -import {compact, isProd} from '../util/util' +import {compact, isProd, parseTheme} from '../util/util' import Cache from './cache' import PluginLoader from './plugin-loader' import {tsPath} from './ts-node' @@ -91,6 +91,7 @@ export class Config implements IConfig { public plugins: Map = new Map() public root!: string public shell!: string + public theme?: Theme public topicSeparator: ' ' | ':' = ':' public userAgent!: string public userPJSON?: PJSON.User @@ -312,6 +313,7 @@ export class Config implements IConfig { if (this.pjson.oclif.topicSeparator && [' ', ':'].includes(this.pjson.oclif.topicSeparator)) this.topicSeparator = this.pjson.oclif.topicSeparator! if (this.platform === 'win32') this.dirname = this.dirname.replace('/', '\\') + this.userAgent = `${this.name}/${this.version} ${this.platform}-${this.arch} node-${process.version}` this.shell = this._shell() this.debug = this._debug() @@ -325,6 +327,12 @@ export class Config implements IConfig { this.npmRegistry = this.scopedEnvVar('NPM_REGISTRY') || this.pjson.oclif.npmRegistry + if (!this.scopedEnvVarTrue('DISABLE_THEME')) { + const themeFilePath = resolve(this.configDir, 'theme.json') + const theme = await safeReadJson>(themeFilePath) + this.theme = theme ? parseTheme(theme) : undefined + } + this.pjson.oclif.update = this.pjson.oclif.update || {} this.pjson.oclif.update.node = this.pjson.oclif.update.node || {} const s3 = this.pjson.oclif.update.s3 || {} @@ -605,7 +613,7 @@ export class Config implements IConfig { } public scopedEnvVarTrue(k: string): boolean { - const v = process.env[this.scopedEnvVarKeys(k).find((k) => process.env[k]) as string] + const v = this.scopedEnvVar(k) return v === '1' || v === 'true' } diff --git a/src/help/command.ts b/src/help/command.ts index 4755a121a..f1a587387 100644 --- a/src/help/command.ts +++ b/src/help/command.ts @@ -7,19 +7,13 @@ import {ensureArgObject} from '../util/ensure-arg-object' import {castArray, compact, sortBy} from '../util/util' import {DocOpts} from './docopts' import {HelpFormatter, HelpSection, HelpSectionRenderer} from './formatter' +import {colorize} from './util' // Don't use os.EOL because we need to ensure that a string // written on any platform, that may use \r\n or \n, will be // split on any platform, not just the os specific EOL at runtime. const POSSIBLE_LINE_FEED = /\r\n|\n/ -let {dim} = chalk - -if (process.env.ConEmuANSI === 'ON') { - // eslint-disable-next-line unicorn/consistent-destructuring - dim = chalk.gray -} - export class CommandHelp extends HelpFormatter { constructor( public command: Command.Loadable, @@ -31,7 +25,15 @@ export class CommandHelp extends HelpFormatter { protected aliases(aliases: string[] | undefined): string | undefined { if (!aliases || aliases.length === 0) return - const body = aliases.map((a) => ['$', this.config.bin, a].join(' ')).join('\n') + const body = aliases + .map((a) => + [ + colorize(this.config?.theme?.dollarSign, '$'), + colorize(this.config?.theme?.bin, this.config.bin), + colorize(this.config?.theme?.alias, a), + ].join(' '), + ) + .join('\n') return body } @@ -47,9 +49,14 @@ export class CommandHelp extends HelpFormatter { return args.map((a) => { const name = a.name.toUpperCase() let description = a.description || '' - if (a.default) description = `[default: ${a.default}] ${description}` - if (a.options) description = `(${a.options.join('|')}) ${description}` - return [name, description ? dim(description) : undefined] + if (a.default) + description = `${colorize(this.config?.theme?.flagDefaultValue, `[default: ${a.default}]`)} ${description}` + if (a.options) + description = `${colorize(this.config?.theme?.flagOptions, `(${a.options.join('|')})`)} ${description}` + return [ + colorize(this.config?.theme?.flag, name), + description ? colorize(this.config?.theme?.sectionDescription, description) : undefined, + ] }) } @@ -82,7 +89,7 @@ export class CommandHelp extends HelpFormatter { } if (description) { - return this.wrap(description.join('\n')) + return this.wrap(colorize(this.config?.theme?.commandSummary, description.join('\n'))) } } @@ -125,7 +132,7 @@ export class CommandHelp extends HelpFormatter { return `${this.wrap(description, finalIndentedSpacing)}\n\n${multilineCommands}` }) .join('\n\n') - return body + return colorize(this.config?.theme?.sectionDescription, body) } protected flagHelpLabel(flag: Command.Flag.Any, showOptions = false): string { @@ -142,7 +149,7 @@ export class CommandHelp extends HelpFormatter { } } - label = labels.join(', ') + label = labels.join(colorize(this.config?.theme?.flagSeparator, ', ')) } if (flag.type === 'option') { @@ -163,20 +170,20 @@ export class CommandHelp extends HelpFormatter { if (flags.length === 0) return return flags.map((flag) => { - const left = this.flagHelpLabel(flag) + const left = colorize(this.config?.theme?.flag, this.flagHelpLabel(flag)) let right = flag.summary || flag.description || '' if (flag.type === 'option' && flag.default) { - right = `[default: ${flag.default}] ${right}` + right = `${colorize(this.config?.theme?.flagDefaultValue, `[default: ${flag.default}]`)} ${right}` } - if (flag.required) right = `(required) ${right}` + if (flag.required) right = `${colorize(this.config?.theme?.flagRequired, '(required)')} ${right}` if (flag.type === 'option' && flag.options && !flag.helpValue && !this.opts.showFlagOptionsInTitle) { - right += `\n` + right += colorize(this.config?.theme?.flagOptions, `\n`) } - return [left, dim(right.trim())] + return [left, colorize(this.config?.theme?.sectionDescription, right.trim())] }) } @@ -197,7 +204,7 @@ export class CommandHelp extends HelpFormatter { }) .join('\n\n') - return body + return colorize(this.config?.theme?.sectionDescription, body) } generate(): string { @@ -305,7 +312,16 @@ export class CommandHelp extends HelpFormatter { const body = (usage ? castArray(usage) : [this.defaultUsage()]) .map((u) => { const allowedSpacing = this.opts.maxWidth - this.indentSpacing - const line = `$ ${this.config.bin} ${u}`.trim() + + const dollarSign = colorize(this.config?.theme?.dollarSign, '$') + const bin = colorize(this.config?.theme?.bin, this.config.bin) + const command = colorize(this.config?.theme?.command, '<%= command.id %>') + const commandDescription = colorize( + this.config?.theme?.sectionDescription, + u.replace('<%= command.id %>', '').trim(), + ) + + const line = `${dollarSign} ${bin} ${command} ${commandDescription}`.trim() if (line.length > allowedSpacing) { const splitIndex = line.slice(0, Math.max(0, allowedSpacing)).lastIndexOf(' ') return ( @@ -323,13 +339,16 @@ export class CommandHelp extends HelpFormatter { private formatIfCommand(example: string): string { example = this.render(example) - if (example.startsWith(this.config.bin)) return dim(`$ ${example}`) - if (example.startsWith(`$ ${this.config.bin}`)) return dim(example) + const dollarSign = colorize(this.config?.theme?.dollarSign, '$') + if (example.startsWith(this.config.bin)) return `${dollarSign} ${example}` + if (example.startsWith(`$ ${this.config.bin}`)) return `${dollarSign}${example.replace(`$`, '')}` return example } private isCommand(example: string): boolean { - return stripAnsi(this.formatIfCommand(example)).startsWith(`$ ${this.config.bin}`) + return stripAnsi(this.formatIfCommand(example)).startsWith( + `${colorize(this.config?.theme?.dollarSign, '$')} ${this.config.bin}`, + ) } } export default CommandHelp diff --git a/src/help/formatter.ts b/src/help/formatter.ts index 9a4b0d9e0..67ea0da76 100644 --- a/src/help/formatter.ts +++ b/src/help/formatter.ts @@ -8,7 +8,7 @@ import wrap from 'wrap-ansi' import {Command} from '../command' import * as Interfaces from '../interfaces' import {stdtermwidth} from '../screen' -import {template} from './util' +import {colorize, template} from './util' export type HelpSectionKeyValueTable = {description: string; name: string}[] export type HelpSection = @@ -176,7 +176,7 @@ export class HelpFormatter { } const output = [ - chalk.bold(header), + colorize(this.config?.theme?.sectionHeader, chalk.bold(header)), this.indent( Array.isArray(newBody) ? this.renderList(newBody, {indentation: 2, stripAnsi: this.opts.stripAnsi}) : newBody, ), diff --git a/src/help/index.ts b/src/help/index.ts index f43f17dd4..3dced2591 100644 --- a/src/help/index.ts +++ b/src/help/index.ts @@ -11,7 +11,13 @@ import {compact, sortBy, uniqBy} from '../util/util' import {CommandHelp} from './command' import {HelpFormatter} from './formatter' import RootHelp from './root' -import {formatCommandDeprecationWarning, getHelpFlagAdditions, standardizeIDFromArgv, toConfiguredId} from './util' +import { + colorize, + formatCommandDeprecationWarning, + getHelpFlagAdditions, + standardizeIDFromArgv, + toConfiguredId, +} from './util' export {CommandHelp} from './command' export {getHelpFlagAdditions, normalizeArgv, standardizeIDFromArgv} from './util' @@ -80,10 +86,10 @@ export class Help extends HelpBase { protected description(c: Command.Loadable): string { const description = this.render(c.description || '') if (c.summary) { - return description + return colorize(this.config?.theme?.sectionDescription, description) } - return description.split('\n').slice(1).join('\n') + return colorize(this.config?.theme?.sectionDescription, description.split('\n').slice(1).join('\n')) } protected formatCommand(command: Command.Loadable): string { @@ -103,7 +109,11 @@ export class Help extends HelpBase { .filter((c) => (this.opts.hideAliasesFromRoot ? !c.aliases?.includes(c.id) : true)) .map((c) => { if (this.config.topicSeparator !== ':') c.id = c.id.replaceAll(':', this.config.topicSeparator) - return [c.id, this.summary(c)] + const summary = this.summary(c) + return [ + colorize(this.config?.theme?.command, c.id), + summary && colorize(this.config?.theme?.sectionDescription, summary), + ] }), { indentation: 2, @@ -127,9 +137,16 @@ export class Help extends HelpBase { let topicID = `${topic.name}:COMMAND` if (this.config.topicSeparator !== ':') topicID = topicID.replaceAll(':', this.config.topicSeparator) let output = compact([ - summary, - this.section(this.opts.usageHeader || 'USAGE', `$ ${this.config.bin} ${topicID}`), - description && this.section('DESCRIPTION', this.wrap(description)), + colorize(this.config?.theme?.commandSummary, summary), + this.section( + this.opts.usageHeader || 'USAGE', + `${colorize(this.config?.theme?.dollarSign, '$')} ${colorize( + this.config?.theme?.bin, + this.config.bin, + )} ${topicID}`, + ), + description && + this.section('DESCRIPTION', this.wrap(colorize(this.config?.theme?.sectionDescription, description))), ]).join('\n\n') if (this.opts.stripAnsi) output = stripAnsi(output) return output + '\n' @@ -140,7 +157,10 @@ export class Help extends HelpBase { const body = this.renderList( topics.map((c) => { if (this.config.topicSeparator !== ':') c.name = c.name.replaceAll(':', this.config.topicSeparator) - return [c.name, c.description && this.render(c.description.split('\n')[0])] + return [ + colorize(this.config?.theme?.topic, c.name), + c.description && this.render(colorize(this.config?.theme?.sectionDescription, c.description.split('\n')[0])), + ] }), { indentation: 2, @@ -334,9 +354,9 @@ export class Help extends HelpBase { } protected summary(c: Command.Loadable): string | undefined { - if (c.summary) return this.render(c.summary.split('\n')[0]) + if (c.summary) return colorize(this.config?.theme?.commandSummary, this.render(c.summary.split('\n')[0])) - return c.description && this.render(c.description).split('\n')[0] + return c.description && colorize(this.config?.theme?.commandSummary, this.render(c.description).split('\n')[0]) } /* diff --git a/src/help/root.ts b/src/help/root.ts index 3955dc68c..a62a1bf37 100644 --- a/src/help/root.ts +++ b/src/help/root.ts @@ -3,6 +3,7 @@ import stripAnsi from 'strip-ansi' import * as Interfaces from '../interfaces' import {compact} from '../util/util' import {HelpFormatter} from './formatter' +import {colorize} from './util' export default class RootHelp extends HelpFormatter { constructor( @@ -17,23 +18,36 @@ export default class RootHelp extends HelpFormatter { description = this.render(description) description = description.split('\n').slice(1).join('\n') if (!description) return - return this.section('DESCRIPTION', this.wrap(description)) + return this.section('DESCRIPTION', this.wrap(colorize(this.config?.theme?.sectionDescription, description))) } root(): string { let description = this.config.pjson.oclif.description || this.config.pjson.description || '' description = this.render(description) description = description.split('\n')[0] - let output = compact([description, this.version(), this.usage(), this.description()]).join('\n\n') + let output = compact([ + colorize(this.config?.theme?.commandSummary, description), + this.version(), + this.usage(), + this.description(), + ]).join('\n\n') if (this.opts.stripAnsi) output = stripAnsi(output) return output } protected usage(): string { - return this.section(this.opts.usageHeader || 'USAGE', this.wrap(`$ ${this.config.bin} [COMMAND]`)) + return this.section( + this.opts.usageHeader || 'USAGE', + this.wrap( + `${colorize(this.config?.theme?.dollarSign, '$')} ${colorize( + this.config?.theme?.bin, + this.config.bin, + )} ${colorize(this.config?.theme?.sectionDescription, '[COMMAND]')}`, + ), + ) } protected version(): string { - return this.section('VERSION', this.wrap(this.config.userAgent)) + return this.section('VERSION', this.wrap(colorize(this.config?.theme?.version, this.config.userAgent))) } } diff --git a/src/help/util.ts b/src/help/util.ts index 82db784f5..12b5f0dbf 100644 --- a/src/help/util.ts +++ b/src/help/util.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk' +import * as Color from 'color' import * as ejs from 'ejs' import {collectUsableIds} from '../config/util' @@ -107,3 +109,7 @@ export function normalizeArgv(config: IConfig, argv = process.argv.slice(2)): st if (config.topicSeparator !== ':' && !argv[0]?.includes(':')) argv = standardizeIDFromArgv(argv, config) return argv } + +export function colorize(color: Color | undefined, text: string): string { + return color ? chalk.hex(color.hex())(text) : text +} diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index 9a2e73c55..e2bae560c 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -1,3 +1,5 @@ +import * as Color from 'color' + import {Command} from '../command' import {Hook, Hooks} from './hooks' import {PJSON} from './pjson' @@ -24,6 +26,26 @@ export type VersionDetails = { shell?: string } +export const THEME_KEYS = [ + 'alias', + 'bin', + 'command', + 'commandSummary', + 'dollarSign', + 'flag', + 'flagDefaultValue', + 'flagOptions', + 'flagRequired', + 'flagSeparator', + 'flagType', + 'sectionDescription', + 'sectionHeader', + 'topic', + 'version', +] + +export type Theme = Record + export interface Config { /** * process.arch @@ -122,6 +144,7 @@ export interface Config { * active shell */ readonly shell: string + readonly theme?: Theme topicSeparator: ' ' | ':' readonly topics: Topic[] /** diff --git a/src/util/util.ts b/src/util/util.ts index 6b3048a27..4849f6128 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -1,3 +1,7 @@ +import * as Color from 'color' + +import {THEME_KEYS, Theme} from '../interfaces/config' + export function pickBy>( obj: T, fn: (i: T[keyof T]) => boolean, @@ -105,3 +109,16 @@ function get(obj: Record, path: string): unknown { export function mergeNestedObjects(objs: Record[], path: string): Record { return Object.fromEntries(objs.flatMap((o) => Object.entries(get(o, path) ?? {})).reverse()) } + +export function parseTheme(untypedTheme: Record): Theme { + return Object.fromEntries( + Object.entries(untypedTheme) + .filter(([key]) => THEME_KEYS.includes(key)) + .map(([key, value]) => [key, getColor(value)]), + ) +} + +export function getColor(color: string) { + // eslint-disable-next-line new-cap + return new Color.default(color) +} diff --git a/test/config/config.test.ts b/test/config/config.test.ts index 76cf0cb9f..26378a590 100644 --- a/test/config/config.test.ts +++ b/test/config/config.test.ts @@ -45,12 +45,14 @@ const pjson = { } describe('Config', () => { - const testConfig = ({pjson, homedir = '/my/home', platform = 'darwin', env = {}}: Options = {}) => { + const testConfig = ({pjson, homedir = '/my/home', platform = 'darwin', env = {}}: Options = {}, theme?: any) => { let test = fancy .resetConfig() .env(env, {clear: true}) .stub(os, 'getHomeDir', (stub) => stub.returns(join(homedir))) .stub(os, 'getPlatform', (stub) => stub.returns(platform)) + + if (theme) test = test.stub(fs, 'safeReadJson', (stub) => stub.resolves(theme)) if (pjson) test = test.stub(fs, 'readJson', (stub) => stub.resolves(pjson)) test = test.add('config', () => Config.load()) @@ -401,4 +403,48 @@ describe('Config', () => { }, ) }) + + describe('theme', () => { + testConfig({pjson, env: {FOO_DISABLE_THEME: 'true'}}, {bin: 'red'}).it( + 'should not be set when DISABLE_THEME is true and theme.json exists', + (config) => { + expect(config).to.have.property('theme', undefined) + }, + ) + + testConfig({pjson, env: {FOO_DISABLE_THEME: 'false'}}, {bin: 'red'}).it( + 'should be set when DISABLE_THEME is false and theme.json exists', + (config) => { + expect(config).to.nested.include({'theme.bin.color[0]': 255}) + }, + ) + + testConfig({pjson, env: {}}, {bin: 'red'}).it( + 'should be set when DISABLE_THEME is unset and theme.json exists', + (config) => { + expect(config).to.nested.include({'theme.bin.color[0]': 255}) + }, + ) + + testConfig({pjson, env: {FOO_DISABLE_THEME: 'true'}}).it( + 'should not be set when DISABLE_THEME is true and theme.json does not exist', + (config) => { + expect(config).to.have.property('theme', undefined) + }, + ) + + testConfig({pjson, env: {FOO_DISABLE_THEME: 'false'}}).it( + 'should not be set when DISABLE_THEME is false and theme.json does not exist', + (config) => { + expect(config).to.have.property('theme', undefined) + }, + ) + + testConfig({pjson, env: {}}).it( + 'should not be set when DISABLE_THEME is unset and theme.json does not exist', + (config) => { + expect(config).to.have.property('theme', undefined) + }, + ) + }) }) diff --git a/test/help/format-command-with-options.test.ts b/test/help/format-command-with-options.test.ts index 5e0abf7bf..24c45718a 100644 --- a/test/help/format-command-with-options.test.ts +++ b/test/help/format-command-with-options.test.ts @@ -321,24 +321,24 @@ ARGUMENTS const cmd = await makeLoadable( makeCommandClass({ id: 'apps:create', - usage: '<%= config.bin %> <%= command.id %> usage', + usage: '<%= command.id %> usage', }), ) const output = help.formatCommand(cmd) expect(output).to.equal(`USAGE - $ oclif oclif apps:create usage`) + $ oclif apps:create usage`) }) it('should output usage arrays with templates', async () => { const cmd = await makeLoadable( makeCommandClass({ id: 'apps:create', - usage: ['<%= config.bin %>', '<%= command.id %> usage'], + usage: ['<%= config.id %>', '<%= command.id %> usage'], }), ) const output = help.formatCommand(cmd) expect(output).to.equal(`USAGE - $ oclif oclif + $ oclif apps:create $ oclif apps:create usage`) }) diff --git a/test/help/format-command.test.ts b/test/help/format-command.test.ts index b9f466ae0..7365db709 100644 --- a/test/help/format-command.test.ts +++ b/test/help/format-command.test.ts @@ -524,26 +524,26 @@ ARGUMENTS const cmd = await makeLoadable( makeCommandClass({ id: 'apps:create', - usage: '<%= config.bin %> <%= command.id %> usage', + usage: '<%= command.id %> usage', }), ) const output = help.formatCommand(cmd) expect(output).to.equal(`USAGE - $ oclif oclif apps:create usage`) + $ oclif apps:create usage`) }) it('should output usage arrays with templates', async () => { const cmd = await makeLoadable( makeCommandClass({ id: 'apps:create', - usage: ['<%= config.bin %>', '<%= command.id %> usage'], + usage: ['<%= command.id %>', '<%= command.id %> usage'], }), ) const output = help.formatCommand(cmd) expect(output).to.equal(`USAGE - $ oclif oclif + $ oclif apps:create $ oclif apps:create usage`) }) diff --git a/test/help/util.test.ts b/test/help/util.test.ts index d2a27e19a..8cb233822 100644 --- a/test/help/util.test.ts +++ b/test/help/util.test.ts @@ -1,10 +1,13 @@ import {test} from '@oclif/test' import {expect} from 'chai' +import chalk from 'chalk' import {resolve} from 'node:path' import {Config, Interfaces} from '../../src' import * as util from '../../src/config/util' import {loadHelpClass, standardizeIDFromArgv} from '../../src/help' +import {colorize} from '../../src/help/util' +import {getColor} from '../../src/util/util' import configuredHelpClass from './_test-help-class' describe('util', () => { @@ -183,4 +186,28 @@ describe('util', () => { }, ) }) + + describe('colorize', () => { + const color = getColor('red') + + it('should return text with ansi characters when given color', () => { + const text = colorize(color, 'brazil') + expect(text).to.equal(chalk.hex(color.hex())('brazil')) + }) + + it('should return text without ansi characters when given undefined', () => { + const text = colorize(undefined, 'brazil') + expect(text).to.equal('brazil') + }) + + it('should return empty text without ansi characters when given color', () => { + const text = colorize(color, '') + expect(text).to.equal('') + }) + + it('should return empty text without ansi characters when given undefined', () => { + const text = colorize(undefined, '') + expect(text).to.equal('') + }) + }) }) diff --git a/test/util/util.test.ts b/test/util/util.test.ts index d2c00b57b..42c318ab7 100644 --- a/test/util/util.test.ts +++ b/test/util/util.test.ts @@ -1,6 +1,17 @@ import {expect} from 'chai' -import {capitalize, castArray, isNotFalsy, isTruthy, last, maxBy, mergeNestedObjects, sumBy} from '../../src/util/util' +import {THEME_KEYS} from '../../src/interfaces/config' +import { + capitalize, + castArray, + isNotFalsy, + isTruthy, + last, + maxBy, + mergeNestedObjects, + parseTheme, + sumBy, +} from '../../src/util/util' describe('capitalize', () => { it('capitalizes the string', () => { @@ -128,3 +139,191 @@ describe('mergeNestedObjects', () => { }) }) }) + +describe('theme parsing', () => { + it('should parse untyped theme json to theme', () => { + const untypedTheme = { + alias: '#FFFFFF', + bin: '#FFFFFF', + command: '#FFFFFF', + commandSummary: '#FFFFFF', + dollarSign: '#FFFFFF', + flag: '#FFFFFF', + flagDefaultValue: '#FFFFFF', + flagOptions: '#FFFFFF', + flagRequired: '#FFFFFF', + flagSeparator: '#FFFFFF', + flagType: '#FFFFFF', + sectionDescription: '#FFFFFF', + sectionHeader: '#FFFFFF', + topic: '#FFFFFF', + version: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + for (const key of Object.keys(theme)) { + expect(THEME_KEYS.includes(key)).to.be.true + } + }) + + it('should parse alias', () => { + const untypedTheme = { + alias: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.alias?.hex()).to.equal('#FFFFFF') + }) + + it('should parse bin', () => { + const untypedTheme = { + bin: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.bin?.hex()).to.equal('#FFFFFF') + }) + + it('should parse command', () => { + const untypedTheme = { + command: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.command?.hex()).to.equal('#FFFFFF') + }) + + it('should parse commandSummary', () => { + const untypedTheme = { + commandSummary: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.commandSummary?.hex()).to.equal('#FFFFFF') + }) + + it('should parse dollarSign', () => { + const untypedTheme = { + dollarSign: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.dollarSign?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flag', () => { + const untypedTheme = { + flag: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flag?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flagDefaultValue', () => { + const untypedTheme = { + flagDefaultValue: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flagDefaultValue?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flagOptions', () => { + const untypedTheme = { + flagOptions: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flagOptions?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flagRequired', () => { + const untypedTheme = { + flagRequired: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flagRequired?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flagSeparator', () => { + const untypedTheme = { + flagSeparator: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flagSeparator?.hex()).to.equal('#FFFFFF') + }) + + it('should parse flagType', () => { + const untypedTheme = { + flagType: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.flagType?.hex()).to.equal('#FFFFFF') + }) + + it('should parse sectionDescription', () => { + const untypedTheme = { + sectionDescription: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.sectionDescription?.hex()).to.equal('#FFFFFF') + }) + + it('should parse sectionHeader', () => { + const untypedTheme = { + sectionHeader: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.sectionHeader?.hex()).to.equal('#FFFFFF') + }) + + it('should parse topic', () => { + const untypedTheme = { + topic: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.topic?.hex()).to.equal('#FFFFFF') + }) + + it('should parse version', () => { + const untypedTheme = { + version: '#FFFFFF', + } + + const theme = parseTheme(untypedTheme) + + expect(theme.version?.hex()).to.equal('#FFFFFF') + }) + + it('should not parse color key that is not part of Theme', () => { + const untypedTheme = { + batman: '#000000', + } + + const theme = parseTheme(untypedTheme) + + expect(Object.keys(theme).includes('batman')).to.be.false + }) +}) diff --git a/yarn.lock b/yarn.lock index c319598bc..2845fdfb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1012,11 +1012,25 @@ dependencies: "@types/node" "*" +"@types/color-convert@*": + version "2.0.2" + resolved "http://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.2.tgz#a5fa5da9b866732f8bf86b01964869011e2a2356" + integrity sha512-KGRIgCxwcgazts4MXRCikPbIMzBpjfdgEZSy8TRHU/gtg+f9sOfHdtK8unPfxIoBtyd2aTTwINVLSNENlC8U8A== + dependencies: + "@types/color-name" "*" + "@types/color-name@*": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/color@^3.0.5": + version "3.0.5" + resolved "http://registry.yarnpkg.com/@types/color/-/color-3.0.5.tgz#658fd9286a44c21dabaa56c2e2f63da3ac15f063" + integrity sha512-T9yHCNtd8ap9L/r8KEESu5RDMLkoWXHo7dTureNoI1dbp25NsCN054vOu09iniIjR21MXUL+LU9bkIWrbyg8gg== + dependencies: + "@types/color-convert" "*" + "@types/debug@^4.1.10": version "4.1.10" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.10.tgz#f23148a6eb771a34c466a4fc28379d8101e84494" @@ -2091,16 +2105,32 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.1.4, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.9.0: + version "1.9.1" + resolved "http://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +color@^4.2.3: + version "4.2.3" + resolved "http://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + colorette@^2.0.20: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" @@ -3844,6 +3874,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= +is-arrayish@^0.3.1: + version "0.3.2" + resolved "http://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -6282,6 +6317,13 @@ sigstore@^1.3.0, sigstore@^1.4.0, sigstore@^1.7.0: "@sigstore/tuf" "^1.0.3" make-fetch-happen "^11.0.1" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "http://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + sinon@^16.0.0, sinon@^16.1.0: version "16.1.0" resolved "https://registry.yarnpkg.com/sinon/-/sinon-16.1.0.tgz#645b836563c9bedb21defdbe48831cb2afb687f2"