JSON formatter

Format JSON data in a beautiful way.

As of 2019-04-19. See the latest version.

  1. (function () {
  2. 'use strict';
  3.  
  4. // ==UserScript==
  5. // @name JSON formatter
  6. // @namespace http://gerald.top
  7. // @author Gerald <i@gerald.top>
  8. // @icon http://cn.gravatar.com/avatar/a0ad718d86d21262ccd6ff271ece08a3?s=80
  9. // @description Format JSON data in a beautiful way.
  10. // @description:zh-CN 更加漂亮地显示JSON数据。
  11. // @version 2.0.4
  12. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@1
  13. // @require https://cdn.jsdelivr.net/npm/lossless-json@1.0.3
  14. // @match *://*/*
  15. // @match file:///*
  16. // @grant GM_getValue
  17. // @grant GM_setValue
  18. // @grant GM_addStyle
  19. // @grant GM_registerMenuCommand
  20. // @grant GM_setClipboard
  21. // ==/UserScript==
  22.  
  23. 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}.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}";
  24.  
  25. const h = VM.createElement;
  26. const gap = 5;
  27. const formatter = {
  28. options: [{
  29. key: 'show-quotes',
  30. title: '"',
  31. def: true
  32. }, {
  33. key: 'show-commas',
  34. title: ',',
  35. def: true
  36. }]
  37. };
  38. const config = { ...formatter.options.reduce((res, item) => {
  39. res[item.key] = item.def;
  40. return res;
  41. }, {}),
  42. ...GM_getValue('config')
  43. };
  44. if (['application/json', 'text/plain', 'application/javascript', 'text/javascript'].includes(document.contentType)) formatJSON();
  45. GM_registerMenuCommand('Toggle JSON format', formatJSON);
  46.  
  47. function createQuote() {
  48. return h("span", {
  49. className: "subtle quote"
  50. }, "\"");
  51. }
  52.  
  53. function createComma() {
  54. return h("span", {
  55. className: "subtle comma"
  56. }, ",");
  57. }
  58.  
  59. function loadJSON() {
  60. const raw = document.body.innerText; // LosslessJSON is much slower than native JSON, so we just use it for small JSON files.
  61.  
  62. const JSON = raw.length > 1024000 ? window.JSON : window.LosslessJSON;
  63.  
  64. try {
  65. // JSON
  66. const content = JSON.parse(raw);
  67. return {
  68. raw,
  69. content
  70. };
  71. } catch (e) {// not JSON
  72. }
  73.  
  74. try {
  75. // JSONP
  76. const parts = raw.match(/^(.*?\w\s*\()(.+)(\)[;\s]*)$/);
  77. const content = JSON.parse(parts[2]);
  78. return {
  79. raw,
  80. content,
  81. prefix: h("span", {
  82. className: "subtle"
  83. }, parts[1].trim()),
  84. suffix: h("span", {
  85. className: "subtle"
  86. }, parts[3].trim())
  87. };
  88. } catch (e) {// not JSONP
  89. }
  90. }
  91.  
  92. function formatJSON() {
  93. if (formatter.formatted) return;
  94. formatter.formatted = true;
  95. formatter.data = loadJSON();
  96. if (!formatter.data) return;
  97. formatter.style = GM_addStyle(css);
  98. formatter.root = h("div", {
  99. id: "json-formatter"
  100. });
  101. document.body.innerHTML = '';
  102. document.body.append(formatter.root);
  103. initTips();
  104. initMenu();
  105. bindEvents();
  106. generateNodes(formatter.data, formatter.root);
  107. }
  108.  
  109. function generateNodes(data, container) {
  110. const rootSpan = h("span", null);
  111. const root = h("div", null, rootSpan);
  112. const pre = h("pre", null, root);
  113. formatter.pre = pre;
  114. const queue = [{
  115. el: rootSpan,
  116. elBlock: root,
  117. ...data
  118. }];
  119.  
  120. while (queue.length) {
  121. const item = queue.shift();
  122. const {
  123. el,
  124. content,
  125. prefix,
  126. suffix
  127. } = item;
  128. if (prefix) el.append(prefix);
  129.  
  130. if (Array.isArray(content)) {
  131. queue.push(...generateArray(item));
  132. } else if (isObject(content)) {
  133. queue.push(...generateObject(item));
  134. } else {
  135. const type = typeOf(content);
  136. if (type === 'string') el.append(createQuote());
  137. const node = h("span", {
  138. className: `${type} item`,
  139. "data-type": type,
  140. "data-value": toString(content)
  141. }, toString(content));
  142. el.append(node);
  143. if (type === 'string') el.append(createQuote());
  144. }
  145.  
  146. if (suffix) el.append(suffix);
  147. }
  148.  
  149. container.append(pre);
  150. updateView();
  151. }
  152.  
  153. function isObject(item) {
  154. if (item instanceof window.LosslessJSON.LosslessNumber) return false;
  155. return item && typeof item === 'object';
  156. }
  157.  
  158. function typeOf(item) {
  159. if (item == null) return 'null';
  160. if (item instanceof window.LosslessJSON.LosslessNumber) return 'number';
  161. return typeof item;
  162. }
  163.  
  164. function toString(content) {
  165. if (content instanceof window.LosslessJSON.LosslessNumber) return content.toString();
  166. return `${content}`;
  167. }
  168.  
  169. function setFolder(el, length) {
  170. if (length) {
  171. el.classList.add('complex');
  172. el.append(h("div", {
  173. className: "folder"
  174. }, '\u25b8'), h("span", {
  175. className: "summary"
  176. }, `// ${length} items`));
  177. }
  178. }
  179.  
  180. function generateArray({
  181. el,
  182. elBlock,
  183. content
  184. }) {
  185. const elContent = content.length && h("div", {
  186. className: "content"
  187. });
  188. setFolder(elBlock, content.length);
  189. el.append(h("span", {
  190. className: "bracket"
  191. }, "["), elContent || ' ', h("span", {
  192. className: "bracket"
  193. }, "]"));
  194. return content.map((item, i) => {
  195. const elValue = h("span", null);
  196. const elChild = h("div", null, elValue);
  197. elContent.append(elChild);
  198. if (i < content.length - 1) elChild.append(createComma());
  199. return {
  200. el: elValue,
  201. elBlock: elChild,
  202. content: item
  203. };
  204. });
  205. }
  206.  
  207. function generateObject({
  208. el,
  209. elBlock,
  210. content
  211. }) {
  212. const keys = Object.keys(content);
  213. const elContent = keys.length && h("div", {
  214. className: "content"
  215. });
  216. setFolder(elBlock, keys.length);
  217. el.append(h("span", {
  218. className: "bracket"
  219. }, '{'), elContent || ' ', h("span", {
  220. className: "bracket"
  221. }, '}'));
  222. return keys.map((key, i) => {
  223. const elValue = h("span", null);
  224. const elChild = h("div", null, createQuote(), h("span", {
  225. className: "key item",
  226. "data-type": typeof key
  227. }, key), createQuote(), ': ', elValue);
  228. if (i < keys.length - 1) elChild.append(createComma());
  229. elContent.append(elChild);
  230. return {
  231. el: elValue,
  232. content: content[key],
  233. elBlock: elChild
  234. };
  235. });
  236. }
  237.  
  238. function updateView() {
  239. formatter.options.forEach(({
  240. key
  241. }) => {
  242. formatter.pre.classList[config[key] ? 'add' : 'remove'](key);
  243. });
  244. }
  245.  
  246. function removeEl(el) {
  247. el.remove();
  248. }
  249.  
  250. function initMenu() {
  251. const handleCopy = () => {
  252. GM_setClipboard(formatter.data.raw);
  253. };
  254.  
  255. const handleMenuClick = e => {
  256. const el = e.target;
  257. const {
  258. key
  259. } = el.dataset;
  260.  
  261. if (key) {
  262. config[key] = !config[key];
  263. GM_setValue('config', config);
  264. el.classList.toggle('active');
  265. updateView();
  266. }
  267. };
  268.  
  269. formatter.root.append(h("div", {
  270. className: "menu",
  271. onClick: handleMenuClick
  272. }, h("span", {
  273. onClick: handleCopy
  274. }, "Copy"), formatter.options.map(item => h("span", {
  275. className: `toggle${config[item.key] ? ' active' : ''}`,
  276. dangerouslySetInnerHTML: {
  277. __html: item.title
  278. },
  279. "data-key": item.key
  280. }))));
  281. }
  282.  
  283. function initTips() {
  284. const tips = h("div", {
  285. className: "tips",
  286. onClick: e => {
  287. e.stopPropagation();
  288. }
  289. });
  290.  
  291. const hide = () => removeEl(tips);
  292.  
  293. document.addEventListener('click', hide, false);
  294. formatter.tips = {
  295. node: tips,
  296. hide,
  297.  
  298. show(range) {
  299. const {
  300. scrollTop
  301. } = document.body;
  302. const rects = range.getClientRects();
  303. let rect;
  304.  
  305. if (rects[0].top < 100) {
  306. rect = rects[rects.length - 1];
  307. tips.style.top = `${rect.bottom + scrollTop + gap}px`;
  308. tips.style.bottom = '';
  309. } else {
  310. [rect] = rects;
  311. tips.style.top = '';
  312. tips.style.bottom = `${formatter.root.offsetHeight - rect.top - scrollTop + gap}px`;
  313. }
  314.  
  315. tips.style.left = `${rect.left}px`;
  316. const {
  317. type,
  318. value
  319. } = range.startContainer.dataset;
  320. tips.innerHTML = '';
  321. tips.append(h("span", {
  322. className: "tips-key"
  323. }, "type"), ': ', h("span", {
  324. className: "tips-val",
  325. dangerouslySetInnerHTML: {
  326. __html: type
  327. }
  328. }));
  329.  
  330. if (type === 'string' && /^(https?|ftps?):\/\/\S+/.test(value)) {
  331. tips.append(h("br", null), h("a", {
  332. className: "tips-link",
  333. href: value,
  334. target: "_blank",
  335. rel: "noopener noreferrer"
  336. }, "Open link"));
  337. }
  338.  
  339. formatter.root.append(tips);
  340. }
  341.  
  342. };
  343. }
  344.  
  345. function selectNode(node) {
  346. const selection = window.getSelection();
  347. selection.removeAllRanges();
  348. const range = document.createRange();
  349. range.setStartBefore(node.firstChild);
  350. range.setEndAfter(node.firstChild);
  351. selection.addRange(range);
  352. return range;
  353. }
  354.  
  355. function bindEvents() {
  356. formatter.root.addEventListener('click', e => {
  357. e.stopPropagation();
  358. const {
  359. target
  360. } = e;
  361.  
  362. if (target.classList.contains('item')) {
  363. formatter.tips.show(selectNode(target));
  364. } else {
  365. formatter.tips.hide();
  366. }
  367.  
  368. if (target.classList.contains('folder')) {
  369. target.parentNode.classList.toggle('collapse');
  370. }
  371. }, false);
  372. }
  373.  
  374. }());