JSON formatter

Format JSON data in a beautiful way.

目前为 2017-12-06 提交的版本,查看 最新版本

  1. 'use strict';
  2.  
  3. // ==UserScript==
  4. // @name JSON formatter
  5. // @namespace http://gerald.top
  6. // @author Gerald <i@gerald.top>
  7. // @icon http://cn.gravatar.com/avatar/a0ad718d86d21262ccd6ff271ece08a3?s=80
  8. // @description Format JSON data in a beautiful way.
  9. // @description:zh-CN 更加漂亮地显示JSON数据。
  10. // @version 2.0.1
  11. // @match *://*/*
  12. // @match file:///*
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_addStyle
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_setClipboard
  18. // ==/UserScript==
  19.  
  20. const gap = 5;
  21.  
  22. const formatter = {
  23. options: [{
  24. key: 'show-quotes',
  25. title: '"',
  26. def: true
  27. }, {
  28. key: 'show-commas',
  29. title: ',',
  30. def: true
  31. }]
  32. };
  33.  
  34. const config = Object.assign({}, formatter.options.reduce((res, item) => {
  35. res[item.key] = item.def;
  36. return res;
  37. }, {}), GM_getValue('config'));
  38.  
  39. if (['application/json', 'text/plain', 'application/javascript', 'text/javascript'].includes(document.contentType)) formatJSON();
  40. GM_registerMenuCommand('Toggle JSON format', formatJSON);
  41.  
  42. function safeHTML(html) {
  43. return String(html).replace(/[<&"]/g, key => ({
  44. '<': '&lt;',
  45. '&': '&amp;',
  46. '"': '&quot;'
  47. })[key]);
  48. }
  49.  
  50. function createElement(tag, props) {
  51. const el = document.createElement(tag);
  52. if (props) {
  53. Object.keys(props).forEach(key => {
  54. el[key] = props[key];
  55. });
  56. }
  57. return el;
  58. }
  59.  
  60. function createQuote() {
  61. return createElement('span', {
  62. className: 'subtle quote',
  63. textContent: '"'
  64. });
  65. }
  66.  
  67. function createComma() {
  68. return createElement('span', {
  69. className: 'subtle comma',
  70. textContent: ','
  71. });
  72. }
  73.  
  74. function loadJSON() {
  75. const raw = document.body.innerText;
  76. try {
  77. // JSON
  78. const content = JSON.parse(raw);
  79. return { raw, content };
  80. } catch (e) {
  81. // not JSON
  82. }
  83. try {
  84. // JSONP
  85. const parts = raw.match(/^(.*?\w\s*\()(.+)(\)[;\s]*)$/);
  86. const content = JSON.parse(parts[2]);
  87. return {
  88. raw,
  89. content,
  90. prefix: createElement('span', {
  91. className: 'subtle',
  92. textContent: parts[1].trim()
  93. }),
  94. suffix: createElement('span', {
  95. className: 'subtle',
  96. textContent: parts[3].trim()
  97. })
  98. };
  99. } catch (e) {
  100. // not JSONP
  101. }
  102. }
  103.  
  104. function formatJSON() {
  105. if (formatter.formatted) return;
  106. formatter.formatted = true;
  107. formatter.data = loadJSON();
  108. if (!formatter.data) return;
  109. formatter.style = GM_addStyle(".tips-link {\n color: slateblue;\n}.tips-val {\n color: dodgerblue;\n}.complex.collapse::before {\n display: none;\n}* {\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n font-family: Menlo, \"Microsoft YaHei\", Tahoma;\n}\n\n#json-formatter {\n position: relative;\n margin: 0;\n padding: 2em 1em 1em 2em;\n font-size: 14px;\n line-height: 1.5;\n}\n\n#json-formatter > pre {\n white-space: pre-wrap\n}\n\n#json-formatter > pre:not(.show-quotes) .quote, #json-formatter > pre:not(.show-commas) .comma {\n display: none;\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.content {\n padding-left: 2em;\n}\n.collapse > span > .content {\n display: inline;\n padding-left: 0;\n}\n.collapse > span > .content > * {\n display: none;\n}\n.collapse > span > .content::before {\n content: '...';\n}\n.complex {\n position: relative\n}\n.complex::before {\n content: '';\n position: absolute;\n top: 1.5em;\n left: -.5em;\n bottom: .7em;\n margin-left: -1px;\n border-left: 1px dashed currentColor;\n}\n.folder {\n color: #999;\n position: absolute;\n top: 0;\n left: -1em;\n width: 1em;\n text-align: center;\n transform: rotate(90deg);\n transition: transform .3s;\n}\n.collapse > .folder {\n transform: rotate(0);\n}\n.folder::before {\n content: '\\25B8';\n}\n.summary {\n color: #999;\n margin-left: 1em;\n}\n*:not(.collapse) > .summary {\n display: none;\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 z-index: 10;\n}\n.menu > span {\n display: inline-block;\n padding: 4px 8px;\n margin-right: 5px;\n border-radius: 4px;\n background: #ddd;\n border: 1px solid #ddd;\n cursor: pointer\n}\n.menu > span.toggle:not(.active) {\n background: none;\n}\n");
  110. formatter.root = createElement('div', { id: 'json-formatter' });
  111. document.body.innerHTML = '';
  112. document.body.append(formatter.root);
  113. initTips();
  114. initMenu();
  115. bindEvents();
  116. generateNodes(formatter.data, formatter.root);
  117. }
  118.  
  119. function generateNodes(data, container) {
  120. const pre = createElement('pre');
  121. formatter.pre = pre;
  122. const root = createElement('div');
  123. const rootSpan = createElement('span');
  124. root.append(rootSpan);
  125. pre.append(root);
  126. const queue = [Object.assign({ el: rootSpan, elBlock: root }, data)];
  127. while (queue.length) {
  128. const item = queue.shift();
  129. const { el, content, prefix, suffix } = item;
  130. if (prefix) el.append(prefix);
  131. if (Array.isArray(content)) {
  132. queue.push(...generateArray(item));
  133. } else if (content && typeof content === 'object') {
  134. queue.push(...generateObject(item));
  135. } else {
  136. const type = content == null ? 'null' : typeof content;
  137. if (type === 'string') el.append(createQuote());
  138. const node = createElement('span', {
  139. className: `${type} item`,
  140. textContent: `${content}`
  141. });
  142. node.dataset.type = type;
  143. node.dataset.value = content;
  144. el.append(node);
  145. if (type === 'string') el.append(createQuote());
  146. }
  147. if (suffix) el.append(suffix);
  148. }
  149. container.append(pre);
  150. updateView();
  151. }
  152.  
  153. function setFolder(el, length) {
  154. if (length) {
  155. el.classList.add('complex');
  156. el.append(createElement('div', {
  157. className: 'folder'
  158. }));
  159. el.append(createElement('span', {
  160. textContent: `// ${length} items`,
  161. className: 'summary'
  162. }));
  163. }
  164. }
  165.  
  166. function generateArray({ el, elBlock, content }) {
  167. const elContent = content.length && createElement('div', {
  168. className: 'content'
  169. });
  170. setFolder(elBlock, content.length);
  171. el.append(createElement('span', {
  172. textContent: '[',
  173. className: 'bracket'
  174. }), elContent || ' ', createElement('span', {
  175. textContent: ']',
  176. className: 'bracket'
  177. }));
  178. return content.map((item, i) => {
  179. const elChild = createElement('div');
  180. elContent.append(elChild);
  181. const elValue = createElement('span');
  182. elChild.append(elValue);
  183. if (i < content.length - 1) elChild.append(createComma());
  184. return {
  185. el: elValue,
  186. elBlock: elChild,
  187. content: item
  188. };
  189. });
  190. }
  191.  
  192. function generateObject({ el, elBlock, content }) {
  193. const keys = Object.keys(content);
  194. const elContent = keys.length && createElement('div', {
  195. className: 'content'
  196. });
  197. setFolder(elBlock, keys.length);
  198. el.append(createElement('span', {
  199. textContent: '{',
  200. className: 'bracket'
  201. }), elContent || ' ', createElement('span', {
  202. textContent: '}',
  203. className: 'bracket'
  204. }));
  205. return keys.map((key, i) => {
  206. const elChild = createElement('div');
  207. elContent.append(elChild);
  208. const elValue = createElement('span');
  209. const node = createElement('span', {
  210. className: 'key item',
  211. textContent: key
  212. });
  213. node.dataset.type = typeof key;
  214. elChild.append(createQuote(), node, createQuote(), ': ', elValue);
  215. if (i < keys.length - 1) elChild.append(createComma());
  216. return { el: elValue, content: content[key], elBlock: elChild };
  217. });
  218. }
  219.  
  220. function updateView() {
  221. formatter.options.forEach(({ key }) => {
  222. formatter.pre.classList[config[key] ? 'add' : 'remove'](key);
  223. });
  224. }
  225.  
  226. function removeEl(el) {
  227. el.remove();
  228. }
  229.  
  230. function initMenu() {
  231. const menu = createElement('div', {
  232. className: 'menu'
  233. });
  234. const btnCopy = createElement('span', {
  235. textContent: 'Copy'
  236. });
  237. btnCopy.addEventListener('click', () => {
  238. GM_setClipboard(formatter.data.raw);
  239. }, false);
  240. menu.append(btnCopy);
  241. formatter.options.forEach(item => {
  242. const span = createElement('span', {
  243. className: `toggle${config[item.key] ? ' active' : ''}`,
  244. innerHTML: item.title
  245. });
  246. span.dataset.key = item.key;
  247. menu.append(span);
  248. });
  249. menu.addEventListener('click', e => {
  250. const el = e.target;
  251. const { key } = el.dataset;
  252. if (key) {
  253. config[key] = !config[key];
  254. GM_setValue('config', config);
  255. el.classList.toggle('active');
  256. updateView();
  257. }
  258. }, false);
  259. formatter.root.append(menu);
  260. }
  261.  
  262. function initTips() {
  263. const tips = createElement('div', {
  264. className: 'tips'
  265. });
  266. const hide = () => removeEl(tips);
  267. tips.addEventListener('click', e => {
  268. e.stopPropagation();
  269. }, false);
  270. document.addEventListener('click', hide, false);
  271. formatter.tips = {
  272. node: tips,
  273. hide,
  274. show(range) {
  275. const scrollTop = document.body.scrollTop;
  276. const rects = range.getClientRects();
  277. let rect;
  278. if (rects[0].top < 100) {
  279. rect = rects[rects.length - 1];
  280. tips.style.top = `${rect.bottom + scrollTop + gap}px`;
  281. tips.style.bottom = '';
  282. } else {
  283. rect = rects[0];
  284. tips.style.top = '';
  285. tips.style.bottom = `${formatter.root.offsetHeight - rect.top - scrollTop + gap}px`;
  286. }
  287. tips.style.left = `${rect.left}px`;
  288. const { type, value } = range.startContainer.dataset;
  289. const html = [`<span class="tips-key">type</span>: <span class="tips-val">${safeHTML(type)}</span>`];
  290. if (type === 'string' && /^(https?|ftps?):\/\/\S+/.test(value)) {
  291. html.push('<br>', `<a class="tips-link" href="${encodeURI(value)}" target="_blank">Open link</a>`);
  292. }
  293. tips.innerHTML = html.join('');
  294. formatter.root.append(tips);
  295. }
  296. };
  297. }
  298.  
  299. function selectNode(node) {
  300. const selection = window.getSelection();
  301. selection.removeAllRanges();
  302. const range = document.createRange();
  303. range.setStartBefore(node.firstChild);
  304. range.setEndAfter(node.firstChild);
  305. selection.addRange(range);
  306. return range;
  307. }
  308.  
  309. function bindEvents() {
  310. formatter.root.addEventListener('click', e => {
  311. e.stopPropagation();
  312. const { target } = e;
  313. if (target.classList.contains('item')) {
  314. formatter.tips.show(selectNode(target));
  315. } else {
  316. formatter.tips.hide();
  317. }
  318. if (target.classList.contains('folder')) {
  319. target.parentNode.classList.toggle('collapse');
  320. }
  321. }, false);
  322. }