Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable themes (#852) #862

Merged
merged 19 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from 17 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
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'
39 changes: 39 additions & 0 deletions src/cli-ux/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import chalk from 'chalk'
import * as Color from 'color'

import {STANDARD_CHALK, StandardChalk, Theme, Themes} 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: Themes): Theme {
const themes = theme.themes ?? {}
const selected = theme.selected ? themes[theme.selected] ?? {} : {}
return Object.fromEntries(
Object.entries(selected)
.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
32 changes: 29 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, Themes} 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 {activeTheme} = await this.loadThemes()
this.theme = activeTheme
}

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,23 @@ export class Config implements IConfig {
}
}

public async loadThemes(): Promise<{
file: string
activeTheme: Theme | undefined
themes: Themes | undefined
}> {
const themesFile = this.pjson.oclif.themesFile
? resolve(this.root, this.pjson.oclif.themesFile)
: resolve(this.configDir, 'themes.json')
const themes = await safeReadJson<Themes>(themesFile)
const activeTheme = themes ? parseTheme(themes) : undefined
return {
activeTheme,
file: themesFile,
themes,
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why loading all themes in memory if only one is used at a time

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not answer the question. What is the use case to have all themes in memory?

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