Skip to content

Commit

Permalink
feat: enable themes (#852) (#862)
Browse files Browse the repository at this point in the history
* feat: create type for storing theme colors

* feat: include color and @types/color, and simiplify theme schema

* feat: set theme's default colors to white

* feat: set FORCE_COLOR to 0||3 to follow the NO_COLOR manifest

* feat: create DEFAULT_THEME to store default colors

* revert: remove NO_COLOR manifest

* feat: add new theme variables to style $, flag, and flag options

* feat: add color to section headers

* feat: configure default colors

* feat: add colors for bin, command summary and version

* feat: topics, commands, bin, version, sections, dollar sign are colorized

* feat: configure default colors

* feat: add feature flag to enabled/disable theme

* feat: add colorize function to simplify the way colors are added to strings

* feat: change all chalk.hex calls to colorize

* feat: configure OCLIF_ENABLE_THEME to have precence over PJSON prop

* feat: all theme colors are optional

* fix: error TS2322: Type 'string' is not assignable to type 'boolean'

* fix: error TS2345: arg of type 'Color<ColorParam>|undefined' not assignable to 'Color<ColorParam>'

* fix: runtime error TypeError: color.hex is not a function

* fix:  this.pjson.oclif.enableTheme was not being evaluated when OCLIF_ENABLE_THEME was unset

* chore(deps): update yarn.lock

* refactor: simplified code removing OCLIF_ENABLE_THEME constant

* fix: command summary was not changing its color when running the root command

* feat: theme is now read from ~/config/<CLI>/theme.json if one exists

* fix: add colors to other ARGUMENTS, EXAMPLES, DESCRIPTION, FLAGS DESCRIPTIONS sections

* feat: add color token for aliases

* fix(test): change template strings to avoid expected results printing bin twice

* fix(test): add a missing parenthesis back so that all options are wraped by ()

* fix(test): remove single quotes wraping default flag values

* feat: add test:debug script to ease debuging

* refactor: add a constant with all possible THEME_KEYS

* test: add tests to prove parsing untyped json object with color strings work

* chore(package): add new scripts to ease development while writing tests

* revert: remove theme from PJSON because a theme will be loaded from config_dir/<CLI>/theme.json

* feat: add scopedEnvVarBoolean because scopedEnvVarTrue does not consider unset env vars

* feat(test): add tests to prove the behavior that enables theme

* refactor: replace all scopedEnvVarTrue by scopedEnvVarBoolean, which considers unset env variables

* test: add tests to prove the enableTheme behavior

* refactor: simplify parseTheme method

* chore(package): remove localhost:4873 from lock file

* revert: rever scopedEnvVarBoolean back to scopedEnvVarTrue

* test: add tests to prove when this.theme is set

* feat: ensure colorize returns string only

* test: add tests to prove colorize works as expected

* revert: rollback scopedEnvVarTrue as it was before

* refactor: move parseTheme to src/util/util.js

* refactor: make Theme type dinamically based on the values of THEME_KEYS at runtime

* fix: err TS1259: Module /@types/color/index can only be default-imported using 'esModuleInterop'

* revert: remove enableTheme from pjson because we don't want to cli devs to force users to use theme

* revert: remove undefined as a return type for scopedEnvVarTrue

* refactor: remove config.enableTheme

Co-authored-by: Allan Oricil <55927613+AllanOricil@users.noreply.github.com>
  • Loading branch information
mdonnalley and AllanOricil authored Nov 20, 2023
1 parent 23fdcf9 commit da2bd5b
Show file tree
Hide file tree
Showing 21 changed files with 758 additions and 1,086 deletions.
1,273 changes: 261 additions & 1,012 deletions CHANGELOG.md

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@oclif/core",
"description": "base library for oclif CLIs",
"version": "3.11.0",
"version": "3.11.1-dev.1",
"author": "Salesforce",
"bugs": "https://github.com/oclif/core/issues",
"dependencies": {
Expand All @@ -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",
Expand Down Expand Up @@ -44,7 +45,8 @@
"@types/chai-as-promised": "^7.1.8",
"@types/clean-stack": "^2.1.1",
"@types/cli-progress": "^3.11.0",
"@types/debug": "^4.1.12",
"@types/color": "^3.0.5",
"@types/debug": "^4.1.10",
"@types/ejs": "^3.1.3",
"@types/indent-string": "^4.0.1",
"@types/js-yaml": "^3.12.7",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/cli-ux/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import * as uxPrompt from './prompt'
import * as styled from './styled'
import uxWait from './wait'
import write from './write'

const hyperlinker = require('hyperlinker')

export class ux {
Expand Down Expand Up @@ -195,4 +194,5 @@ export {ExitError} from './exit'
export {IPromptOptions} from './prompt'
export {Table} from './styled'

export {colorize} from './theme'
export {default as write} from './write'
37 changes: 37 additions & 0 deletions src/cli-ux/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import chalk from 'chalk'
import * as Color from 'color'

import {STANDARD_CHALK, StandardChalk, Theme} from '../interfaces/theme'

function isStandardChalk(color: any): color is StandardChalk {
return STANDARD_CHALK.includes(color)
}

/**
* Add color to text.
* @param color color to use. Can be hex code (e.g. `#ff0000`), rgb (e.g. `rgb(255, 255, 255)`) or a chalk color (e.g. `red`)
* @param text string to colorize
* @returns colorized string
*/
export function colorize(color: string | StandardChalk | undefined, text: string): string {
if (isStandardChalk(color)) return chalk[color](text)

return color ? chalk.hex(color)(text) : text
}

export function parseTheme(theme: Record<string, string>): Theme {
return Object.fromEntries(
Object.entries(theme)
.map(([key, value]) => [key, getColor(value)])
.filter(([_, value]) => value),
)
}

export function getColor(color: string): string
export function getColor(color: StandardChalk): StandardChalk
export function getColor(color: string | StandardChalk): string | StandardChalk | undefined {
try {
// eslint-disable-next-line new-cap
return isStandardChalk(color) ? color : new Color.default(color).hex()
} catch {}
}
3 changes: 2 additions & 1 deletion src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {ux} from './cli-ux'
import {Config} from './config'
import * as Errors from './errors'
import {PrettyPrintableError} from './errors'
import {formatCommandDeprecationWarning, formatFlagDeprecationWarning, normalizeArgv, toConfiguredId} from './help/util'
import {formatCommandDeprecationWarning, formatFlagDeprecationWarning, normalizeArgv} from './help/util'
import {PJSON} from './interfaces'
import {LoadOptions} from './interfaces/config'
import {CommandError} from './interfaces/errors'
Expand All @@ -28,6 +28,7 @@ import {Plugin} from './interfaces/plugin'
import * as Parser from './parser'
import {aggregateFlags} from './util/aggregate-flags'
import {requireJson} from './util/fs'
import {toConfiguredId} from './util/ids'
import {uniq} from './util/util'

const pjson = requireJson<PJSON>(__dirname, '..', 'package.json')
Expand Down
28 changes: 25 additions & 3 deletions src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
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'
import {parseTheme} from '../cli-ux/theme'
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 {Plugin as IPlugin, Options} from '../interfaces/plugin'
import {Theme} from '../interfaces/theme'
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 Cache from './cache'
Expand Down Expand Up @@ -91,6 +93,7 @@ export class Config implements IConfig {
public plugins: Map<string, IPlugin> = new Map()
public root!: string
public shell!: string
public theme?: Theme
public topicSeparator: ' ' | ':' = ':'
public userAgent!: string
public userPJSON?: PJSON.User
Expand Down Expand Up @@ -312,6 +315,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()
Expand All @@ -325,6 +329,11 @@ export class Config implements IConfig {

this.npmRegistry = this.scopedEnvVar('NPM_REGISTRY') || this.pjson.oclif.npmRegistry

if (!this.scopedEnvVarTrue('DISABLE_THEME')) {
const {theme} = await this.loadThemes()
this.theme = theme
}

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 || {}
Expand Down Expand Up @@ -390,6 +399,19 @@ export class Config implements IConfig {
}
}

public async loadThemes(): Promise<{
file: string
theme: Theme | undefined
}> {
const file = resolve(this.configDir, 'theme.json')
const themes = await safeReadJson<Record<string, string>>(file)
const theme = themes ? parseTheme(themes) : undefined
return {
file,
theme,
}
}

protected macosCacheDir(): string | undefined {
return (this.platform === 'darwin' && join(this.home, 'Library', 'Caches', this.dirname)) || undefined
}
Expand Down Expand Up @@ -605,7 +627,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'
}

Expand Down
67 changes: 45 additions & 22 deletions src/help/command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import chalk from 'chalk'
import stripAnsi from 'strip-ansi'

import {colorize} from '../cli-ux/theme'
import {Command} from '../command'
import * as Interfaces from '../interfaces'
import {ensureArgObject} from '../util/ensure-arg-object'
import {toStandardizedId} from '../util/ids'
import {castArray, compact, sortBy} from '../util/util'
import {DocOpts} from './docopts'
import {HelpFormatter, HelpSection, HelpSectionRenderer} from './formatter'
Expand All @@ -13,13 +15,6 @@ import {HelpFormatter, HelpSection, HelpSectionRenderer} from './formatter'
// 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,
Expand All @@ -31,7 +26,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
}

Expand All @@ -47,9 +50,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,
]
})
}

Expand Down Expand Up @@ -142,7 +150,7 @@ export class CommandHelp extends HelpFormatter {
}
}

label = labels.join(flag.char ? ', ' : ' ')
label = labels.join(colorize(this.config?.theme?.flagSeparator, flag.char ? ', ' : ' '))
}

if (flag.type === 'option') {
Expand All @@ -165,22 +173,22 @@ export class CommandHelp extends HelpFormatter {
const noChar = flags.reduce((previous, current) => previous && current.char === undefined, true)

return flags.map((flag) => {
let left = this.flagHelpLabel(flag)
let left = colorize(this.config?.theme?.flag, this.flagHelpLabel(flag))

if (noChar) left = left.replace(' ', '')

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<options: ${flag.options.join('|')}>`
right += colorize(this.config?.theme?.flagOptions, `\n<options: ${flag.options.join('|')}>`)
}

return [left, dim(right.trim())]
return [left, colorize(this.config?.theme?.sectionDescription, right.trim())]
})
}

Expand Down Expand Up @@ -309,11 +317,23 @@ export class CommandHelp extends HelpFormatter {
}

protected usage(): string {
const {usage} = this.command
const {id, usage} = this.command
const standardId = toStandardizedId(id, this.config)
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 %>', '').replace(standardId, '').trim(),
)

const line = `${dollarSign} ${bin} ${command} ${commandDescription}`.trim()
if (line.length > allowedSpacing) {
const splitIndex = line.slice(0, Math.max(0, allowedSpacing)).lastIndexOf(' ')
return (
Expand All @@ -331,13 +351,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
11 changes: 8 additions & 3 deletions src/help/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import stripAnsi from 'strip-ansi'
import widestLine from 'widest-line'
import wrap from 'wrap-ansi'

import {colorize} from '../cli-ux/theme'
import {Command} from '../command'
import * as Interfaces from '../interfaces'
import {stdtermwidth} from '../screen'
Expand Down Expand Up @@ -176,11 +177,15 @@ export class HelpFormatter {
}

const output = [
chalk.bold(header),
this.indent(
Array.isArray(newBody) ? this.renderList(newBody, {indentation: 2, stripAnsi: this.opts.stripAnsi}) : newBody,
colorize(this.config?.theme?.sectionHeader, chalk.bold(header)),
colorize(
this.config?.theme?.sectionDescription,
this.indent(
Array.isArray(newBody) ? this.renderList(newBody, {indentation: 2, stripAnsi: this.opts.stripAnsi}) : newBody,
),
),
].join('\n')

return this.opts.stripAnsi ? stripAnsi(output) : output
}

Expand Down
Loading

0 comments on commit da2bd5b

Please sign in to comment.