JuMastro/chromatic-console

View on GitHub
src/stylizer.js

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
const util = require('util')
const { isPlainObject, prepareOptions } = require('./utils.js')

const DEFAULT_OPTIONS = {
  adds: {}
}

const HASH_MODIFIER = {
  bright: 1,
  dim: 2,
  underline: 4,
  blink: 5,
  hidden: 8
}

const HASH_COLORS = {
  black: [0, 0, 0],
  blue: [0, 0, 255],
  cyan: [0, 215, 175],
  green: [0, 255, 0],
  orange: [95, 95, 0],
  purple: [128, 0, 128],
  red: [255, 0, 0],
  white: [255, 255, 255],
  yellow: [255, 255, 0]
}

const VALID_TRANSFORMER_REGEX = /^\u001b\[((\d{2};\d{1};\d{1,3};\d{1,3};\d{1,3})|(\d{1}))m%s\u001b\[0m$/g

/**
 * Check if a colors as allowed format. ([r, g, b])
 * @param {Array<number>} rgb - The rgb values.
 * @returns {boolean}
 */
function isRgbArray (rgb) {
  return Array.isArray(rgb) &&
    rgb.length === 3 &&
    rgb.length === rgb.filter((v) => Number.isInteger(v) && v <= 255 & v >= 0).length
}

/**
 * Check the stylizer options object.
 * @param {object} options - Stylizer options object.
 * @returns {object}
 */
function checkOptions (options) {
  const opts = prepareOptions(options, DEFAULT_OPTIONS)

  if (!isPlainObject(opts.adds)) {
    throw new TypeError('The "options.adds" must be a plain object.')
  }

  for (const color of Object.values(opts.adds)) {
    if (!isRgbArray(color)) {
      throw new RangeError('The "options.adds" RGB values must be 3 integers between 0 and 255 as array.')
    }
  }

  return opts
}

/**
 * Class Stylizer.
 * Used to create & provide styles to items (strings or object).
 */
module.exports = class Stylizer {
  /**
   * @param {object} options - The options object.
   */
  constructor (options = {}) {
    const opts = checkOptions(options)

    this.adds = opts.adds
    this.styles = {
      modifiers: {},
      foregrounds: {},
      backgrounds: {}
    }
    this.inspect = this.createInspector()

    this._build()
  }

  /**
   * Internal method to build the instance.
   * It use to build transformers methods (modifiers + colors).
   * @returns {void}
   */
  _build () {
    Object.entries({ ...HASH_MODIFIER }).forEach(([name, modifier]) => {
      Object.assign(this.styles.modifiers, {
        [name]: this._formatModifier(modifier)
      })
    })

    Object.entries({ ...HASH_COLORS, ...this.adds }).forEach(([name, color]) => {
      this.addColor(name, color)
    })
  }

  /**
   * Internal to format a color.
   * @param {string} type - The colors type (fg, bg).
   * @param {Array<number>} rgb - The decomposed color as RGB.
   * @returns {string} String style formatted.
   */
  _formatColor (type, [red, green, blue]) {
    return this._formatModifier(`${type};${red};${green};${blue}`)
  }

  /**
   * Internal to format a modifier.
   * @param {string} type - The modifier type.
   * @returns {string} String style formatted.
   */
  _formatModifier (type) {
    return `\x1b[${type}m%s\x1b[0m`
  }

  /**
   * Apply a style pattern to a list of values.
   * @param {string} pattern - The style pattern to apply to the values.
   * @param  {...any} values - The values to apply the pattern.
   * @returns {string} Joined stylized list of value.
   */
  stylize (pattern, ...values) {
    return values
      .map((value) => util.format(pattern, value))
      .join('')
  }

  /**
   * Stylize an object using `util.inspect`
   * @param {object} object - The object to stylize.
   * @returns {string}
   */
  stylizeObject (object) {
    return this.inspect(object)
  }

  /**
   * Create an independant inspector based on util.inspect.
   * @param {object} options - The inspector options.
   * @returns {function} Independant inspector.
   */
  createInspector (options = {}) {
    if (!isPlainObject(options)) {
      throw new TypeError('The "options" argument must be a plain object.')
    }

    if (typeof options.depth === 'undefined') {
      options.depth = Infinity
    }

    if (typeof options.colors === 'undefined') {
      options.colors = true
    }

    const inspector = { ...util.inspect }

    if (isPlainObject(options.colors)) {
      Object.assign(inspector.colors, options.colors)
    }

    if (isPlainObject(options.styles)) {
      Object.assign(inspector.styles, options.styles)
    }

    return function (...args) {
      return args.map((arg) => util.inspect.call(inspector, arg, options)).join('')
    }
  }

  /**
   * Provide a new color to the Stylizer instance.
   * @param {string} name - The color name.
   * @param {Array<number>} color - The colors [R, G, B] values.
   * @param {boolean} redefine - The redefinition state.
   */
  addColor (name, color, redefine = false) {
    if (name in this.styles.foregrounds && !redefine) {
      throw new RangeError(`The "${name}" is already used to another color.`)
    }

    Object.assign(this.styles.foregrounds, {
      [name]: this._formatColor('38;2', color)
    })

    Object.assign(this.styles.backgrounds, {
      [name]: this._formatColor('48;2', color)
    })
  }

  /**
   * Remove a color by name.
   * @param {string} name - The color name.
   * @returns  {boolean}
   */
  removeColor (name) {
    if (!(name in this.styles.foregrounds)) {
      return false
    }

    delete this.styles.foregrounds[name]
    delete this.styles.backgrounds[name]

    return true
  }

  /**
   * Pipe a list of transformers to create uniq styles.
   * @param {Array<string>} transformers - List of transformers.
   * @returns {function}
   */
  pipe (transformers) {
    this.isValidTransformers(transformers)

    return this.stylize.bind(
      null,
      transformers.reduce((acc, next) => this.stylize(acc, next))
    )
  }

  /**
   * Check if list of transformers is valid.
   * @param {Array<string>} transformers - The transformers list
   */
  isValidTransformers (transformers) {
    if (!Array.isArray(transformers) || transformers.length < 1) {
      throw new Error(
        'The "transformers" argument must be an array with atleast 1 transformer.'
      )
    }

    transformers.forEach((transformer) => {
      if (!transformer.match(VALID_TRANSFORMER_REGEX)) {
        throw new Error(
          'The provided transformer is not valid, use addColor method to provide new colors. ' +
          `Received: "${transformer}".`
        )
      }
    })

    return true
  }

  /**
   * Create function set to stylize items.
   * @param {boolean} raw - The raw state.
   * @param {boolean} flat - The flat state.
   * @returns {object}
   */
  createSet (raw = false, flat = true) {
    if (typeof raw !== 'boolean') {
      throw new TypeError('The "raw" argument must be a boolean.')
    }

    if (typeof flat !== 'boolean') {
      throw new TypeError('The "flat" argument must be a boolean.')
    }

    const contentizer = raw
      ? (style) => style
      : (style) => this.stylize.bind(null, style)

    const set = Object.fromEntries(
      Object.entries(this.styles).map(([type, list]) => (
        [type, Object.fromEntries(
          Object.entries(list).map(([name, style]) => (
            [name, contentizer(style)]
          ))
        )]
      ))
    )

    if (flat) {
      return {
        ...set.modifiers,
        ...set.foregrounds,
        ...Object.fromEntries(
          Object.entries(set.backgrounds)
            .map(([name, style]) => [`bg${name}`, style])
        )
      }
    }

    return set
  }
}

Object.defineProperties(module.exports, {
  HASH_COLORS: { value: HASH_COLORS },
  HASH_MODIFIER: { value: HASH_MODIFIER },
  DEFAULT_OPTIONS: { value: DEFAULT_OPTIONS },
  checkOptions: { value: checkOptions },
  isRgbArray: { value: isRgbArray }
})