console-message-v2

Rich text console logging

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/389748/730537/console-message-v2.js

// ==UserScript==
// @name         console-message-v2
// @description  Rich text console logging
// @version      2.0.0
// ==/UserScript==

(() => {

  // Properties whose values have a CSS type
  // of either <integer> or <number>
  const CSS_NUMERIC_PROPERTIES = new Set([
    'animation-iteration-count',
    'border-image-slice',
    'border-image-outset',
    'border-image-width',
    'column-count',
    'counter-increment',
    'fill-opacity',
    'flex-grow',
    'flex-shrink',
    'font-size-adjust',
    'font-weight',
    'grid-column',
    'grid-row',
    'initial-letters',
    'line-clamp',
    'line-height',
    'max-lines',
    'opacity',
    'order',
    'orphans',
    'stop-opacity',
    'stroke-dashoffset',
    'stroke-miterlimit',
    'stroke-opacity',
    'stroke-width',
    'tab-size',
    'widows',
    'z-index',
    'zoom'
  ])

  const _SIMPLE_METHODS = new Set(['groupEnd', 'trace'])
  const _TEXT_METHODS = new Set(['log', 'info', 'warn', 'error'])

  // ----------------------------------------------------------

  // const toCamelCase = s => s.replace(/-\w/g, match => match.charAt(1).toUpperCase())
  const toKebabCase = s => s.replace(/[A-Z]/g, match => '-' + match.toLowerCase())

  function getStyleForCssProps(cssProps) {
    return Object.entries(cssProps)
      .map(([ key, value ]) => {
        const cssProp = toKebabCase(key)

        if (typeof value === 'number' && !CSS_NUMERIC_PROPERTIES.has(cssProp)) {
          value = value + 'px'
        }

        return cssProp + ': ' + value
      })
      .join('; ')
  }

  // ----------------------------------------------------------

  var support = (function () {
    // Taken from https://github.com/jquery/jquery-migrate/blob/master/src/core.js
    function uaMatch(ua) {
      ua = ua.toLowerCase()

      var match = /(chrome)[ \/]([\w.]+)/.exec(ua) ||
        /(webkit)[ \/]([\w.]+)/.exec(ua) ||
        /(opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) ||
        /(msie) ([\w.]+)/.exec(ua) ||
        ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) || []

      return {
        browser: match[1] || "",
        version: match[2] || "0"
      }
    }
    var browserData = uaMatch(navigator.userAgent)

    return {
      isIE: browserData.browser == 'msie' || (browserData.browser == 'mozilla' && parseInt(browserData.version, 10) == 11)
    }
  })()

  // ----------------------------------------------------------

  class ConsoleMessage {
    /** @type ConsoleMessage | null */
    static _currentMessage = null

    static debug = false

    // ----------------------------------------

    constructor () {
      this._rootNode = {
        type: 'root',
        parentNode: null,
        styles: {},
        children: []
      }

      this._currentParent = this._rootNode

      this._calls = new MethodCalls()

      this._waiting = 0
      this._readyCallback = null
    }

    // ----------------------------------------

    extend(obj = {}) {
      const proto = Object.getPrototypeOf(this)
      Object.assign(proto, obj)
      return this
    }

    // ----------------------------------------

    /**
     * Begins a group. By default the group is expanded. Provide false if you want the group to be collapsed.
     * @param {boolean} [expanded = true] -
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    group (collapsed = false) {
      return this._appendNode(collapsed ? 'groupCollapsed' : 'group')
    }

    // ----------------------------------------

    /**
     * Ends the group and returns to writing to the parent message.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    groupEnd () {
      return this._appendNode('groupEnd')
    }

    // ----------------------------------------

    /**
     * Starts a span with particular style and all appended text after it will use the style.
     * @param {Object} styles - The CSS styles to be applied to all text until endSpan() is called
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    span (styles = {}) {
      const parentNode = this._currentParent

      const span = {
        type: 'span',
        parentNode,
        previousStyles: {
          ...parentNode.styles
        },
        styles: {
          ...parentNode.styles,
          ...styles
        },
        children: []
      }

      parentNode.children.push(span)

      this._currentParent = span

      return this
    }

    // ----------------------------------------

    /**
     * Ends the current span styles and backs to the previous styles or the root if there are no other parents.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    spanEnd () {
      if (this._currentParent.type === 'root') {
        throw new Error(`Cannot call spanEnd() without a span`)
      }

      this._currentParent = this._currentParent.parentNode
      return this
    }

    // ----------------------------------------

    /**
     * Appends a text to the current message. All styles in the current span are applied.
     * @param {string} text - The text to be appended
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    text (message, styles) {
      if (typeof styles !== 'object') {
        return this._appendNode('text', { message })
      }

      this.span(styles)

      this._appendNode('text', { message })

      this.spanEnd()

      return this
    }

    // ----------------------------------------

    /**
     * Adds a new line to the output.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    line (method = 'log') {
      return this._appendNode('line', { method })
    }

    // ----------------------------------------

    /**
     * Adds an interactive DOM element to the output.
     * @param {HTMLElement} element - The DOM element to be added.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    element (element) {
      return this._appendNode('element', { element })
    }

    // ----------------------------------------

    /**
     * Adds an interactive object tree to the output.
     * @param {*} object - A value to be added to the output.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    object (object) {
      return this._appendNode('object', { object })
    }

    // ----------------------------------------

    /**
     * Adds an error message and stack trace to the output.
     * @param {Error} error - An error to be added to the output.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    error (error) {
      return this._appendNode('error', { error })
    }

    // ----------------------------------------

    /**
     * Write a stack trace to the console.
     * @returns {ConsoleMessage} - Returns the message object itself to allow chaining.
     */
    trace() {
      return this._appendNode('trace')
    }

    // ----------------------------------------

    _appendNode (type, props = {}) {
      const child = {
        type,
        parentNode: this._currentParent,
        ...props
      }

      this._currentParent.children.push(child)

      return this
    }

    // ----------------------------------------

    /**
     * Prints the message to the console.
     * Until print() is called there will be no result to the console.
     */
    print () {
      if (ConsoleMessage.debug) {
        console.group(`this._rootNode`)
        console.dir(this._rootNode)
      }

      try {
        this._generateMethodCalls(this._rootNode)

        if (ConsoleMessage.debug) {
          console.dir(this._calls)
          console.groupEnd()
        }

        this._calls.executeAll()
      } catch (ex) {
        console.warn(ex)

        if (ConsoleMessage.debug) {
          console.groupEnd()
        }
      }

      ConsoleMessage._currentMessage = null
    }

    // ----------------------------------------

    _generateMethodCalls (node) {
      const calls = this._calls

      switch (node.type) {
        case 'root':
        case 'span':
          calls.appendStyle(node.styles)

          for (const child of node.children) {
            this._generateMethodCalls(child)
          }

          if (node.previousStyles != null) {
            calls.appendStyle(node.previousStyles)
          }

          break

        case 'line':
          calls
            .addCall(node.method)
          break

        case 'group':
        case 'groupCollapsed':
        case 'groupEnd':
        case 'trace':
          calls
            .addCall(node.type)
            .addCall()
          break

        case 'text':
          calls
            .appendText(node.message)
          break

        case 'element':
          calls
            .appendText('%o')
            .addArg(node.element)
          break

        case 'object':
          calls
            .appendText('%O')
            .addArg(node.object)
          break

        case 'error':
          calls
            .addCall('error')
            .addArg(node.error)
            .addCall()
          break
      }
    }
  }

  // ----------------------------------------------------------

  class MethodCalls {
    constructor () {
      this.methodCalls = [
        new MethodCall('log')
      ]
    }

    // ----------------------------------------

    get currentCall () {
      return this.methodCalls[ this.methodCalls.length - 1 ]
    }

    // ----------------------------------------

    addCall (methodName = 'log') {
      if (_TEXT_METHODS.has(methodName) && this.currentCall.canChangeMethod) {
        this.currentCall.changeMethod(methodName)
      } else {
        this.methodCalls.push(new MethodCall(methodName))
      }

      return this
    }

    // ----------------------------------------

    appendText (text) {
      this.currentCall.appendText(text)
      return this
    }

    // ----------------------------------------

    appendStyle (cssProps) {
      this.currentCall.appendStyle(cssProps)
      return this
    }

    // ----------------------------------------

    addArg (arg) {
      this.currentCall.addArg(arg)
      return this
    }

    // ----------------------------------------

    executeAll () {
      this.methodCalls
        .filter(c => c.canExecute)
        .forEach(c => {
          c.execute()
        })
    }
  }

  // ----------------------------------------------------------

  class MethodCall {
    constructor (methodName = 'log') {
      this.methodName = methodName

      this.isSimple = _SIMPLE_METHODS.has(methodName)
      this.canChangeMethod = _TEXT_METHODS.has(methodName)

      this.text = ''
      this.args = []
    }

    // ----------------------------------------

    get lastArg () {
      return this.args[ this.args.length - 1 ]
    }

    // ----------------------------------------

    changeMethod (newMethodName) {
      if (!this.canChangeMethod) {
        throw new Error(`Cannot change method call`)
      }

      if (!_TEXT_METHODS.has(newMethodName)) {
        throw new Error(`Cannot change method call to "${newMethodName}"`)
      }

      this.methodName = newMethodName
      this.canChangeMethod = false
    }

    // ----------------------------------------

    _throwIfSimple () {
      if (this.isSimple) {
        throw new Error(`Calls to console.${this.methodName} cannot have arguments`)
      }
    }

    // ----------------------------------------

    appendText (text, styles) {
      this._throwIfSimple()

      this.text += text
      this.canChangeMethod = false

      return this
    }

    // ----------------------------------------

    appendStyle (cssProps) {
      this._throwIfSimple()

      const css = getStyleForCssProps(cssProps)

      if (this.text.endsWith('%c')) {
        this.args[ this.args.length - 1 ] = css
      } else {
        this.text += '%c'
        this.args.push(css)
      }

      this.canChangeMethod = false

      return this
    }

    // ----------------------------------------

    // updateLastArg (newValue) {
    //   if (this.args.length) {
    //     this.args[ this.args.length ] = newValue
    //   }
    //   return this
    // }

    // ----------------------------------------

    addArg (newArg) {
      this._throwIfSimple()

      this.args.push(newArg)
      this.canChangeMethod = false

      return this
    }

    // ----------------------------------------

    get canExecute () {
      return this.isSimple
        ? true
        : this.text.length > 0 && this.text !== '%c'
    }

    // ----------------------------------------

    execute () {
      const method = console[this.methodName].bind(console)
      method(this.text, ...this.args)
      return this
    }
  }

  // ----------------------------------------------------------

  // console.message().text('woo').text('blue',{color:'#05f'}).line('info').element(document.body).print()

  /**
   * Returns or creates the current message object.
   *
   * @returns {ConsoleMessage} - The message object
   */
  function message() {
    if (ConsoleMessage._currentMessage === null) {
      ConsoleMessage._currentMessage = new ConsoleMessage()
    }

    return ConsoleMessage._currentMessage
  }

  if (window) {
    if (window.console) {
      window.console.message = message
    }

    window.ConsoleMessage = ConsoleMessage
    window.ConsoleMessage.message = message
  }

})()