JSON Prettier

Format JSON data in a beautiful way.

// ==UserScript==
// @name        JSON Prettier
// @icon        https://live.staticflickr.com/65535/52564733798_c1cb151c64_o.png
// @version     1.0.1
// @description Format JSON data in a beautiful way.
// @description:zh-CN 將JSON數據漂亮的呈現,支援縮排與複製。
// @license     MIT
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/dom@1
// @match       *://*/*
// @match       file:///*
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_registerMenuCommand
// @grant       GM_setClipboard
// @namespace   https://greasyfork.org/users/997719
// @author      Wayne
// @homepage    https://wayneblog.ga/
// @homepageURL https://wayneblog.ga/
// ==/UserScript==

(function () {
    'use strict';
    
    const css = "*{margin:0;padding:0}body,html{font-family:Menlo,Microsoft YaHei,Tahoma}#json-formatter{position:relative;margin:0;padding:2em 1em 1em 2em;font-size:14px;line-height:1.5}#json-formatter>pre{white-space:pre-wrap}#json-formatter>pre:not(.show-commas) .comma,#json-formatter>pre:not(.show-quotes) .quote{display:none}.subtle{color:#999}.number{color:#ff8c00}.null{color:grey}.key{color:brown}.string{color:green}.boolean{color:#1e90ff}.bracket{color:#00f}.color{display:inline-block;width:.8em;height:.8em;margin:0 .2em;border:1px solid #666;vertical-align:-.1em}.item{cursor:pointer}.content{padding-left:2em}.collapse>span>.content{display:inline;padding-left:0}.collapse>span>.content>*{display:none}.collapse>span>.content:before{content:\"...\"}.complex{position:relative}.complex:before{content:\"\";position:absolute;top:1.5em;left:-.5em;bottom:.7em;margin-left:-1px;border-left:1px dashed #999}.complex.collapse:before{display:none}.folder{color:#999;position:absolute;top:0;left:-1em;width:1em;text-align:center;transform:rotate(90deg);transition:transform .3s;cursor:pointer}.collapse>.folder{transform:rotate(0)}.summary{color:#999;margin-left:1em}:not(.collapse)>.summary{display:none}.tips{position:absolute;padding:.5em;border-radius:.5em;box-shadow:0 0 1em grey;background:#fff;z-index:1;white-space:nowrap;color:#000}.tips-key{font-weight:700}.tips-val{color:#1e90ff}.tips-link{color:#6a5acd}.menu{position:fixed;top:0;right:0;background:#fff;padding:5px;user-select:none;z-index:10}.menu>span{display:inline-block;padding:4px 8px;margin-right:5px;border-radius:4px;background:#ddd;border:1px solid #ddd;cursor:pointer}.menu>span.toggle:not(.active){background:none}";
    
    const React = VM;
    const gap = 5;
    const formatter = {
      options: [{
        key: 'show-quotes',
        title: '"',
        def: true
      }, {
        key: 'show-commas',
        title: ',',
        def: true
      }]
    };
    const config = { ...formatter.options.reduce((res, item) => {
        res[item.key] = item.def;
        return res;
      }, {}),
      ...GM_getValue('config')
    };
    if (['application/json', 'text/plain', 'application/javascript', 'text/javascript' // file:///foo/bar.js
    ].includes(document.contentType)) formatJSON();
    GM_registerMenuCommand('Toggle JSON format', formatJSON);
    
    function createQuote() {
      return /*#__PURE__*/React.createElement("span", {
        className: "subtle quote"
      }, "\"");
    }
    
    function createComma() {
      return /*#__PURE__*/React.createElement("span", {
        className: "subtle comma"
      }, ",");
    }
    
    function isColor(str) {
      return /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(str);
    }
    
    function tokenize(raw) {
      const skipWhitespace = index => {
        while (index < raw.length && ' \t\r\n'.includes(raw[index])) index += 1;
    
        return index;
      };
    
      const expectIndex = index => {
        if (index < raw.length) return index;
        throw new Error('Unexpected end of input');
      };
    
      const expectChar = (index, white, black) => {
        const ch = raw[index];
    
        if (white && !white.includes(ch) || black && black.includes(ch)) {
          throw new Error(`Unexpected token "${ch}" at ${index}`);
        }
    
        return ch;
      };
    
      const findWord = (index, words) => {
        for (const word of words) {
          if (raw.slice(index, index + word.length) === word) {
            return word;
          }
        }
      };
    
      const expectSpaceAndCharIndex = (index, white, black) => {
        const i = expectIndex(skipWhitespace(index));
        expectChar(i, white, black);
        return i;
      };
    
      const parseString = start => {
        let j;
    
        for (j = start + 1; true; j = expectIndex(j + 1)) {
          const ch = raw[j];
          if (ch === '"') break;
    
          if (ch === '\\') {
            j = expectIndex(j + 1);
            const ch2 = raw[j];
    
            if (ch2 === 'x') {
              j = expectIndex(j + 2);
            } else if (ch2 === 'u') {
              j = expectIndex(j + 4);
            }
          }
        }
    
        const source = raw.slice(start + 1, j);
        return {
          type: 'string',
          source,
          data: source,
          color: isColor(source),
          start,
          end: j + 1
        };
      };
    
      const parseKeyword = start => {
        const nullWord = findWord(start, ['null']);
    
        if (nullWord) {
          return {
            type: 'null',
            source: 'null',
            data: null,
            start,
            end: start + 4
          };
        }
    
        const bool = findWord(start, ['true', 'false']);
    
        if (bool) {
          return {
            type: 'boolean',
            source: bool,
            data: bool === 'true',
            start,
            end: start + bool.length
          };
        }
    
        expectChar(start, '0');
      };
    
      const DIGITS = '0123456789';
    
      const findDecimal = (start, fractional) => {
        let i = start;
        if ('+-'.includes(raw[i])) i += 1;
        let j;
        let dot = -1;
    
        for (j = i; true; j = expectIndex(j + 1)) {
          const ch = expectChar(j, // there must be at least one digit
          // dot must not be the last character of a number, expecting a digit
          j === i || dot >= 0 && dot === j - 1 ? DIGITS : null, // there can be at most one dot
          !fractional || dot >= 0 ? '.' : null);
          if (ch === '.') dot = j;else if (!DIGITS.includes(ch)) break;
        }
    
        return j;
      };
    
      const parseNumber = start => {
        let i = findDecimal(start, true);
        const ch = raw[i];
    
        if (ch && ch.toLowerCase() === 'e') {
          i = findDecimal(i + 1);
        }
    
        const source = raw.slice(start, i);
        return {
          type: 'number',
          source,
          data: +source,
          start,
          end: i
        };
      };
    
      let parseItem;
    
      const parseArray = start => {
        const result = {
          type: 'array',
          data: [],
          start
        };
        let i = start + 1;
    
        while (true) {
          i = expectIndex(skipWhitespace(i));
          if (raw[i] === ']') break;
          if (result.data.length) i = expectSpaceAndCharIndex(i, ',') + 1;
          const item = parseItem(i);
          result.data.push(item);
          i = item.end;
        }
    
        result.end = i + 1;
        return result;
      };
    
      const parseObject = start => {
        const result = {
          type: 'object',
          data: [],
          start
        };
        let i = start + 1;
    
        while (true) {
          i = expectIndex(skipWhitespace(i));
          if (raw[i] === '}') break;
          if (result.data.length) i = expectSpaceAndCharIndex(i, ',') + 1;
          i = expectSpaceAndCharIndex(i, '"');
          const key = parseString(i);
          i = expectSpaceAndCharIndex(key.end, ':') + 1;
          const value = parseItem(i);
          result.data.push({
            key,
            value
          });
          i = value.end;
        }
    
        result.end = i + 1;
        return result;
      };
    
      parseItem = start => {
        const i = expectIndex(skipWhitespace(start));
        const ch = raw[i];
        if (ch === '"') return parseString(i);
        if (ch === '[') return parseArray(i);
        if (ch === '{') return parseObject(i);
        if ('-0123456789'.includes(ch)) return parseNumber(i);
        return parseKeyword(i);
      };
    
      const result = parseItem(0);
      const end = skipWhitespace(result.end);
      if (end < raw.length) expectChar(end, []);
      return result;
    }
    
    function loadJSON() {
      const raw = document.body.innerText;
    
      try {
        // JSON
        const content = tokenize(raw);
        return {
          raw,
          content
        };
      } catch (e) {
        // not JSON
        console.error('Not JSON', e);
      }
    
      try {
        // JSONP
        const parts = raw.match(/^(.*?\w\s*\()(.+)(\)[;\s]*)$/);
        const content = tokenize(parts[2]);
        return {
          raw,
          content,
          prefix: /*#__PURE__*/React.createElement("span", {
            className: "subtle"
          }, parts[1].trim()),
          suffix: /*#__PURE__*/React.createElement("span", {
            className: "subtle"
          }, parts[3].trim())
        };
      } catch (e) {
        // not JSONP
        console.error('Not JSONP', e);
      }
    }
    
    function formatJSON() {
      if (formatter.formatted) return;
      formatter.formatted = true;
      formatter.data = loadJSON();
      if (!formatter.data) return;
      formatter.style = GM_addStyle(css);
      formatter.root = /*#__PURE__*/React.createElement("div", {
        id: "json-formatter"
      });
      document.body.innerHTML = '';
      document.body.append(formatter.root);
      initTips();
      initMenu();
      bindEvents();
      generateNodes(formatter.data, formatter.root);
    }
    
    function generateNodes(data, container) {
      const rootSpan = /*#__PURE__*/React.createElement("span", null);
      const root = /*#__PURE__*/React.createElement("div", null, rootSpan);
      const pre = /*#__PURE__*/React.createElement("pre", null, root);
      formatter.pre = pre;
      const queue = [{
        el: rootSpan,
        elBlock: root,
        ...data
      }];
    
      while (queue.length) {
        const item = queue.shift();
        const {
          el,
          content,
          prefix,
          suffix
        } = item;
        if (prefix) el.append(prefix);
    
        if (content.type === 'array') {
          queue.push(...generateArray(item));
        } else if (content.type === 'object') {
          queue.push(...generateObject(item));
        } else {
          const {
            type,
            color
          } = content;
          if (type === 'string') el.append(createQuote());
          if (color) el.append( /*#__PURE__*/React.createElement("span", {
            className: "color",
            style: `background-color: ${content.data}`
          }));
          el.append( /*#__PURE__*/React.createElement("span", {
            className: `${type} item`,
            "data-type": type,
            "data-value": toString(content)
          }, toString(content)));
          if (type === 'string') el.append(createQuote());
        }
    
        if (suffix) el.append(suffix);
      }
    
      container.append(pre);
      updateView();
    }
    
    function toString(content) {
      return `${content.source}`;
    }
    
    function setFolder(el, length) {
      if (length) {
        el.classList.add('complex');
        el.append( /*#__PURE__*/React.createElement("div", {
          className: "folder"
        }, '\u25b8'), /*#__PURE__*/React.createElement("span", {
          className: "summary"
        }, `// ${length} items`));
      }
    }
    
    function generateArray({
      el,
      elBlock,
      content
    }) {
      const elContent = content.data.length && /*#__PURE__*/React.createElement("div", {
        className: "content"
      });
      setFolder(elBlock, content.data.length);
      el.append( /*#__PURE__*/React.createElement("span", {
        className: "bracket"
      }, "["), elContent || ' ', /*#__PURE__*/React.createElement("span", {
        className: "bracket"
      }, "]"));
      return content.data.map((item, i) => {
        const elValue = /*#__PURE__*/React.createElement("span", null);
        const elChild = /*#__PURE__*/React.createElement("div", null, elValue);
        elContent.append(elChild);
        if (i < content.data.length - 1) elChild.append(createComma());
        return {
          el: elValue,
          elBlock: elChild,
          content: item
        };
      });
    }
    
    function generateObject({
      el,
      elBlock,
      content
    }) {
      const elContent = content.data.length && /*#__PURE__*/React.createElement("div", {
        className: "content"
      });
      setFolder(elBlock, content.data.length);
      el.append( /*#__PURE__*/React.createElement("span", {
        className: "bracket"
      }, '{'), elContent || ' ', /*#__PURE__*/React.createElement("span", {
        className: "bracket"
      }, '}'));
      return content.data.map(({
        key,
        value
      }, i) => {
        const elValue = /*#__PURE__*/React.createElement("span", null);
        const elChild = /*#__PURE__*/React.createElement("div", null, createQuote(), /*#__PURE__*/React.createElement("span", {
          className: "key item",
          "data-type": key.type
        }, key.data), createQuote(), ': ', elValue);
        if (i < content.data.length - 1) elChild.append(createComma());
        elContent.append(elChild);
        return {
          el: elValue,
          content: value,
          elBlock: elChild
        };
      });
    }
    
    function updateView() {
      formatter.options.forEach(({
        key
      }) => {
        formatter.pre.classList[config[key] ? 'add' : 'remove'](key);
      });
    }
    
    function removeEl(el) {
      el.remove();
    }
    
    function initMenu() {
      const handleCopy = () => {
        GM_setClipboard(formatter.data.raw);
      };
    
      const handleCollapse = () => {
        var list = React.getElementsByXPath("//div[@class='complex']");
        for (var i in list) {
          list[i].classList.toggle('collapse');
        }
      };
    
      const handleExpand = () => {
        var list = React.getElementsByXPath("//div[@class='complex collapse']");
        for (var i in list) {
          list[i].classList.toggle('collapse');
        }
      };
    
      const handleMenuClick = e => {
        const el = e.target;
        const {
          key
        } = el.dataset;
    
        if (key) {
          config[key] = !config[key];
          GM_setValue('config', config);
          el.classList.toggle('active');
          updateView();
        }
      };
    
      formatter.root.append( /*#__PURE__*/React.createElement("div", {
        className: "menu",
        onClick: handleMenuClick
      }, /*#__PURE__*/React.createElement("span", {
        onClick: handleCopy
      }, "Copy"), React.createElement("span", {
        onClick: handleExpand
      }, "Expand All"), React.createElement("span", {
        onClick: handleCollapse
      }, "Collapse All"), formatter.options.map(item => /*#__PURE__*/React.createElement("span", {
        className: `toggle${config[item.key] ? ' active' : ''}`,
        dangerouslySetInnerHTML: {
          __html: item.title
        },
        "data-key": item.key
      }))));
    }
    
    function initTips() {
      const tips = /*#__PURE__*/React.createElement("div", {
        className: "tips",
        onClick: e => {
          e.stopPropagation();
        }
      });
    
      const hide = () => removeEl(tips);
    
      document.addEventListener('click', hide, false);
      formatter.tips = {
        node: tips,
        hide,
    
        show(range) {
          const {
            scrollTop
          } = document.body;
          const rects = range.getClientRects();
          let rect;
    
          if (rects[0].top < 100) {
            rect = rects[rects.length - 1];
            tips.style.top = `${rect.bottom + scrollTop + gap}px`;
            tips.style.bottom = '';
          } else {
            [rect] = rects;
            tips.style.top = '';
            tips.style.bottom = `${formatter.root.offsetHeight - rect.top - scrollTop + gap}px`;
          }
    
          tips.style.left = `${rect.left}px`;
          const {
            type,
            value
          } = range.startContainer.dataset;
          tips.innerHTML = '';
          tips.append( /*#__PURE__*/React.createElement("span", {
            className: "tips-key"
          }, "type"), ': ', /*#__PURE__*/React.createElement("span", {
            className: "tips-val",
            dangerouslySetInnerHTML: {
              __html: type
            }
          }));
    
          if (type === 'string' && /^(https?|ftps?):\/\/\S+/.test(value)) {
            tips.append( /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement("a", {
              className: "tips-link",
              href: value,
              target: "_blank",
              rel: "noopener noreferrer"
            }, "Open link"));
          }
    
          formatter.root.append(tips);
        }
    
      };
    }
    
    function selectNode(node) {
      const selection = window.getSelection();
      selection.removeAllRanges();
      const range = document.createRange();
      range.setStartBefore(node.firstChild);
      range.setEndAfter(node.firstChild);
      selection.addRange(range);
      return range;
    }
    
    function bindEvents() {
      formatter.root.addEventListener('click', e => {
        e.stopPropagation();
        const {
          target
        } = e;
    
        if (target.classList.contains('item')) {
          formatter.tips.show(selectNode(target));
        } else {
          formatter.tips.hide();
        }
    
        if (target.classList.contains('folder')) {
          target.parentNode.classList.toggle('collapse');
        }
      }, false);
    }
    
}());