JSON formatter

Format JSON data in a beautiful way.

As of 2017-06-06. See the latest version.

'use strict';

function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }

// ==UserScript==
// @name        JSON formatter
// @namespace   http://gerald.top
// @author      Gerald <[email protected]>
// @icon        http://cn.gravatar.com/avatar/a0ad718d86d21262ccd6ff271ece08a3?s=80
// @description Format JSON data in a beautiful way.
// @description:zh-CN 更加漂亮地显示JSON数据。
// @version     1.4.0
// @match       *://*/*
// @match       file:///*
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_registerMenuCommand
// ==/UserScript==

var id = 0;
var getId = function getId() {
  return id += 1;
};
var SINGLELINE = getId();
var MULTILINE = getId();
var KEY = getId();
var gap = 5;

var createQuote = function createQuote() {
  return createElement('span', {
    className: 'subtle quote',
    textContent: '"'
  });
};
var createComma = function createComma() {
  return createElement('span', {
    className: 'subtle comma',
    textContent: ','
  });
};
var createSpace = function createSpace() {
  var n = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
  return createElement('span', {
    textContent: ' '.repeat(n)
  });
};
var createIndent = function createIndent() {
  var n = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
  return createSpace(2 * n);
};
var createBr = function createBr() {
  return createElement('br');
};

var formatter = {
  options: [{
    key: 'hide-quotes',
    title: '"',
    def: false
  }, {
    key: 'hide-commas',
    title: ',',
    def: false
  }]
};

var config = GM_getValue('config', formatter.options.reduce(function (res, item) {
  res[item.key] = item.def;
  return res;
}, {}));

if (['application/json', 'text/plain', 'application/javascript', 'text/javascript'].includes(document.contentType)) formatJSON();
GM_registerMenuCommand('Toggle JSON format', formatJSON);

function safeHTML(html) {
  return String(html).replace(/[<&"]/g, function (key) {
    return {
      '<': '&lt;',
      '&': '&amp;',
      '"': '&quot;'
    }[key];
  });
}

function createElement(tag, props) {
  var el = document.createElement(tag);
  if (props) {
    Object.keys(props).forEach(function (key) {
      el[key] = props[key];
    });
  }
  return el;
}

function join(rendered) {
  var level = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;

  var arr = [];
  for (var i = 0; i < rendered.length; i += 1) {
    var item = rendered[i];
    var next = rendered[i + 1];
    if (item.data) arr.push.apply(arr, _toConsumableArray(item.data));
    if (next) {
      if (item.separator) arr.push.apply(arr, _toConsumableArray(item.separator));
      if (next.type === KEY || item.type !== KEY && (item.type === SINGLELINE || next.type === SINGLELINE)) {
        arr.push(createBr(), createIndent(level));
      } else {
        arr.push(createSpace(1));
      }
    }
  }
  return arr;
}

function createNodes(data) {
  var valueType = typeof data.value;
  var type = data.type || valueType;
  var el = createElement('span', {
    className: data.cls || `item ${type}`,
    textContent: `${data.value}`
  });
  el.dataset.type = valueType;
  el.dataset.value = data.value;
  var els = [el];
  if (data.type === 'key' || !data.cls && type === 'string') {
    els.unshift(createQuote());
    els.push(createQuote());
  }
  return els;
}

function render(data) {
  var level = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;

  if (Array.isArray(data)) {
    var arr = [];
    var ret = {
      type: MULTILINE,
      separator: [createComma()]
    };
    arr.push.apply(arr, _toConsumableArray(createNodes({ value: '[', cls: 'bracket' })));
    if (data.length) {
      var rendered = data.reduce(function (res, item) {
        return res.concat([render(item, level + 1)]);
      }, []);
      arr.push.apply(arr, [createBr(), createIndent(level + 1)].concat(_toConsumableArray(join(rendered, level + 1)), [createBr(), createIndent(level)]));
    } else {
      arr.push.apply(arr, _toConsumableArray(createNodes({ value: '', cls: 'subtle' })));
      ret.type = SINGLELINE;
    }
    arr.push.apply(arr, _toConsumableArray(createNodes({ value: ']', cls: 'bracket' })));
    ret.data = arr;
    return ret;
  }
  if (data === null) {
    return {
      type: SINGLELINE,
      separator: [createComma()],
      data: createNodes({ value: data, type: 'null' })
    };
  }
  if (typeof data === 'object') {
    var _arr = [];
    var _ret = {
      type: MULTILINE,
      separator: [createComma()]
    };
    _arr.push.apply(_arr, _toConsumableArray(createNodes({ value: '{', cls: 'bracket' })));
    var _rendered = Object.keys(data).reduce(function (res, key) {
      return res.concat([{
        type: KEY,
        data: createNodes({ value: key, type: 'key' }),
        separator: createNodes({ value: ':', cls: 'subtle' })
      }, render(data[key], level + 1)]);
    }, []);
    if (_rendered.length) {
      _arr.push.apply(_arr, [createBr(), createIndent(level + 1)].concat(_toConsumableArray(join(_rendered, level + 1)), [createBr(), createIndent(level)]));
    } else {
      _arr.push.apply(_arr, _toConsumableArray(createNodes({ value: '', cls: 'subtle' })));
      _ret.type = SINGLELINE;
    }
    _arr.push.apply(_arr, _toConsumableArray(createNodes({ value: '}', cls: 'bracket' })));
    _ret.data = _arr;
    return _ret;
  }
  return {
    type: SINGLELINE,
    separator: [createComma()],
    data: createNodes({ value: data })
  };
}

function loadJSON() {
  var text = document.body.innerText;
  try {
    // JSON
    var content = JSON.parse(text);
    return { prefix: '', suffix: '', content };
  } catch (e) {
    // not JSON
  }
  try {
    // JSONP
    var parts = text.match(/^(.*?\w\s*\()(.+)(\)[;\s]*)$/);
    var _content = JSON.parse(parts[2]);
    var prefix = parts[1];
    var suffix = parts[3];
    return { prefix, content: _content, suffix };
  } catch (e) {
    // not JSONP
  }
}

function formatJSON() {
  if (formatter.formatted) {
    formatter.tips.hide();
    formatter.menu.detach();
    document.body.innerHTML = formatter.raw;
    formatter.formatted = false;
  } else {
    if (!('raw' in formatter)) {
      formatter.raw = document.body.innerHTML;
      formatter.data = loadJSON();
      if (!formatter.data) return;
      formatter.style = GM_addStyle(".tips-val {\n    color: dodgerblue;\n}* {\n  margin: 0;\n  padding: 0;\n}\n\nhtml, body {\n  font-family: Menlo, \"Microsoft YaHei\", Tahoma;\n  font-size: 14px;\n}\n\n#root {\n  position: relative;\n  margin: 0;\n  padding: 1rem;\n}\n\n#root > pre {\n    white-space: pre-wrap;\n}\n\n.subtle {\n  color: #999;\n}\n.number {\n  color: darkorange;\n}\n.null {\n  color: gray;\n}\n.key {\n  color: brown;\n}\n.string {\n  color: green;\n}\n.boolean {\n  color: dodgerblue;\n}\n.bracket {\n  color: blue;\n}\n.item {\n  cursor: pointer;\n}\n\n.tips {\n  position: absolute;\n  padding: .5em;\n  border-radius: .5em;\n  box-shadow: 0 0 1em gray;\n  background: white;\n  z-index: 1;\n  white-space: nowrap;\n  color: black\n}\n\n.tips-key {\n    font-weight: bold;\n}\n.menu {\n  position: fixed;\n  top: 0;\n  right: 0;\n  background: white;\n  padding: 5px;\n  user-select: none;\n}\n.menu > span {\n    margin-right: 5px;\n}\n.menu .btn {\n    display: inline-block;\n    width: 18px;\n    height: 18px;\n    line-height: 18px;\n    text-align: center;\n    background: #ddd;\n    border-radius: 4px;\n    cursor: pointer\n}\n.menu .btn.active {\n    color: white;\n    background: #444;\n}\n\n.hide-quotes .quote, .hide-commas .comma {\n  font-size: 0;\n}\n");
      initTips();
      initMenu();
      formatter.render = function () {
        var pre = formatter.pre;
        var _formatter$data = formatter.data,
            prefix = _formatter$data.prefix,
            content = _formatter$data.content,
            suffix = _formatter$data.suffix;

        pre.innerHTML = '';
        [createElement('span', {
          className: 'subtle',
          textContent: prefix
        })].concat(_toConsumableArray(render(content).data), [createElement('span', {
          className: 'subtle',
          textContent: suffix
        })]).forEach(function (el) {
          pre.appendChild(el);
        });
        formatter.update();
      };
      formatter.update = function () {
        formatter.options.forEach(function (_ref) {
          var key = _ref.key;

          formatter.pre.classList[config[key] ? 'add' : 'remove'](key);
        });
      };
    }
    formatter.formatted = true;
    formatter.root = createElement('div', { id: 'root' });
    document.body.innerHTML = '';
    document.body.appendChild(formatter.root);
    formatter.pre = createElement('pre');
    formatter.root.appendChild(formatter.pre);
    formatter.menu.attach();
    bindEvents();
    formatter.render();
  }
}

function removeEl(el) {
  if (el && el.parentNode) el.parentNode.removeChild(el);
}

function initMenu() {
  var menu = createElement('div', {
    className: 'menu'
  });
  formatter.options.forEach(function (item) {
    var span = createElement('span', {
      className: `btn${config[item.key] ? ' active' : ''}`,
      innerHTML: item.title
    });
    span.dataset.key = item.key;
    menu.appendChild(span);
  });
  menu.addEventListener('click', function (e) {
    var el = e.target;
    var key = el.dataset.key;
    if (key) {
      config[key] = !config[key];
      GM_setValue('config', config);
      el.classList.toggle('active');
      formatter.update();
    }
  }, false);
  formatter.menu = {
    node: menu,
    attach() {
      formatter.root.appendChild(menu);
    },
    detach() {
      removeEl(menu);
    }
  };
}

function initTips() {
  var tips = createElement('div', {
    className: 'tips'
  });
  var hide = function hide() {
    return removeEl(tips);
  };
  tips.addEventListener('click', function (e) {
    e.stopPropagation();
  }, false);
  document.addEventListener('click', hide, false);
  formatter.tips = {
    node: tips,
    hide,
    show(range) {
      var scrollTop = document.body.scrollTop;
      var rects = range.getClientRects();
      var rect = void 0;
      if (rects[0].top < 100) {
        rect = rects[rects.length - 1];
        tips.style.top = `${rect.bottom + scrollTop + gap}px`;
        tips.style.bottom = '';
      } else {
        rect = rects[0];
        tips.style.top = '';
        tips.style.bottom = `${formatter.root.offsetHeight - rect.top - scrollTop + gap}px`;
      }
      tips.style.left = `${rect.left}px`;
      tips.innerHTML = `<span class="tips-key">type</span>: <span class="tips-val">${safeHTML(range.startContainer.dataset.type)}</span>`;
      formatter.root.appendChild(tips);
    }
  };
}

function selectNode(node) {
  var selection = window.getSelection();
  selection.removeAllRanges();
  var range = document.createRange();
  range.setStartBefore(node.firstChild);
  range.setEndAfter(node.firstChild);
  selection.addRange(range);
  return range;
}

function bindEvents() {
  formatter.root.addEventListener('click', function (e) {
    e.stopPropagation();
    var target = e.target;

    if (target.classList.contains('item')) {
      formatter.tips.show(selectNode(target));
    } else {
      formatter.tips.hide();
    }
  }, false);
}