Skip to content

Commit

Permalink
feat: enable themes (#852)
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
  • Loading branch information
AllanOricil authored Nov 9, 2023
1 parent f314ff3 commit fc3d81e
Show file tree
Hide file tree
Showing 15 changed files with 482 additions and 56 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,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",
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
18 changes: 13 additions & 5 deletions src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,22 +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 {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'
Expand Down Expand Up @@ -91,6 +91,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 +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()
Expand All @@ -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<Record<string, string>>(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 || {}
Expand Down Expand Up @@ -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'
}

Expand Down
67 changes: 43 additions & 24 deletions src/help/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}

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

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

Expand Down Expand Up @@ -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 {
Expand All @@ -142,7 +149,7 @@ export class CommandHelp extends HelpFormatter {
}
}

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

if (flag.type === 'option') {
Expand All @@ -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<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 All @@ -197,7 +204,7 @@ export class CommandHelp extends HelpFormatter {
})
.join('\n\n')

return body
return colorize(this.config?.theme?.sectionDescription, body)
}

generate(): string {
Expand Down Expand Up @@ -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 (
Expand All @@ -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
4 changes: 2 additions & 2 deletions src/help/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
),
Expand Down
40 changes: 30 additions & 10 deletions src/help/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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])
}

/*
Expand Down
Loading

0 comments on commit fc3d81e

Please sign in to comment.