JSON formatter

Format JSON data in a beautiful way.

目前為 2020-04-23 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name JSON formatter
  3. // @namespace http://gerald.top
  4. // @author Gerald <i@gerald.top>
  5. // @icon http://cn.gravatar.com/avatar/a0ad718d86d21262ccd6ff271ece08a3?s=80
  6. // @description Format JSON data in a beautiful way.
  7. // @description:zh-CN 更加漂亮地显示JSON数据。
  8. // @version 2.0.6
  9. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@1
  10. // @match *://*/*
  11. // @match file:///*
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @grant GM_addStyle
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_setClipboard
  17. // ==/UserScript==
  18.  
  19. (function () {
  20. 'use strict';
  21.  
  22. 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}";
  23.  
  24. const React = VM;
  25. const gap = 5;
  26. const formatter = {
  27. options: [{
  28. key: 'show-quotes',
  29. title: '"',
  30. def: true
  31. }, {
  32. key: 'show-commas',
  33. title: ',',
  34. def: true
  35. }]
  36. };
  37. const config = { ...formatter.options.reduce((res, item) => {
  38. res[item.key] = item.def;
  39. return res;
  40. }, {}),
  41. ...GM_getValue('config')
  42. };
  43. if (['application/json', 'text/plain', 'application/javascript', 'text/javascript' // file:///foo/bar.js
  44. ].includes(document.contentType)) formatJSON();
  45. GM_registerMenuCommand('Toggle JSON format', formatJSON);
  46.  
  47. function createQuote() {
  48. return /*#__PURE__*/React.createElement("span", {
  49. className: "subtle quote"
  50. }, "\"");
  51. }
  52.  
  53. function createComma() {
  54. return /*#__PURE__*/React.createElement("span", {
  55. className: "subtle comma"
  56. }, ",");
  57. }
  58.  
  59. function isColor(str) {
  60. return /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(str);
  61. }
  62.  
  63. function tokenize(raw) {
  64. const skipWhitespace = index => {
  65. while (index < raw.length && ' \t\r\n'.includes(raw[index])) index += 1;
  66.  
  67. return index;
  68. };
  69.  
  70. const expectIndex = index => {
  71. if (index < raw.length) return index;
  72. throw new Error('Unexpected end of input');
  73. };
  74.  
  75. const expectChar = (index, white, black) => {
  76. const ch = raw[index];
  77.  
  78. if (white && !white.includes(ch) || black && black.includes(ch)) {
  79. throw new Error(`Unexpected token "${ch}" at ${index}`);
  80. }
  81.  
  82. return ch;
  83. };
  84.  
  85. const findWord = (index, words) => {
  86. for (const word of words) {
  87. if (raw.slice(index, index + word.length) === word) {
  88. return word;
  89. }
  90. }
  91. };
  92.  
  93. const expectSpaceAndCharIndex = (index, white, black) => {
  94. const i = expectIndex(skipWhitespace(index));
  95. expectChar(i, white, black);
  96. return i;
  97. };
  98.  
  99. const parseString = start => {
  100. let j;
  101.  
  102. for (j = start + 1; true; j = expectIndex(j + 1)) {
  103. const ch = raw[j];
  104. if (ch === '"') break;
  105.  
  106. if (ch === '\\') {
  107. j = expectIndex(j + 1);
  108. const ch2 = raw[j];
  109.  
  110. if (ch2 === 'x') {
  111. j = expectIndex(j + 2);
  112. } else if (ch2 === 'u') {
  113. j = expectIndex(j + 4);
  114. }
  115. }
  116. }
  117.  
  118. const source = raw.slice(start + 1, j);
  119. return {
  120. type: 'string',
  121. source,
  122. data: source,
  123. color: isColor(source),
  124. start,
  125. end: j + 1
  126. };
  127. };
  128.  
  129. const parseKeyword = start => {
  130. const nullWord = findWord(start, ['null']);
  131.  
  132. if (nullWord) {
  133. return {
  134. type: 'null',
  135. source: 'null',
  136. data: null,
  137. start,
  138. end: start + 4
  139. };
  140. }
  141.  
  142. const bool = findWord(start, ['true', 'false']);
  143.  
  144. if (bool) {
  145. return {
  146. type: 'boolean',
  147. source: bool,
  148. data: bool === 'true',
  149. start,
  150. end: start + bool.length
  151. };
  152. }
  153.  
  154. expectChar(start, '0');
  155. };
  156.  
  157. const DIGITS = '0123456789';
  158.  
  159. const findDecimal = (start, fractional) => {
  160. let i = start;
  161. if ('+-'.includes(raw[i])) i += 1;
  162. let j;
  163. let dot = -1;
  164.  
  165. for (j = i; true; j = expectIndex(j + 1)) {
  166. const ch = expectChar(j, // there must be at least one digit
  167. // dot must not be the last character of a number, expecting a digit
  168. j === i || dot >= 0 && dot === j - 1 ? DIGITS : null, // there can be at most one dot
  169. !fractional || dot >= 0 ? '.' : null);
  170. if (ch === '.') dot = j;else if (!DIGITS.includes(ch)) break;
  171. }
  172.  
  173. return j;
  174. };
  175.  
  176. const parseNumber = start => {
  177. let i = findDecimal(start, true);
  178. const ch = raw[i];
  179.  
  180. if (ch && ch.toLowerCase() === 'e') {
  181. i = findDecimal(i + 1);
  182. }
  183.  
  184. const source = raw.slice(start, i);
  185. return {
  186. type: 'number',
  187. source,
  188. data: +source,
  189. start,
  190. end: i
  191. };
  192. };
  193.  
  194. let parseItem;
  195.  
  196. const parseArray = start => {
  197. const result = {
  198. type: 'array',
  199. data: [],
  200. start
  201. };
  202. let i = start + 1;
  203.  
  204. while (true) {
  205. i = expectIndex(skipWhitespace(i));
  206. if (raw[i] === ']') break;
  207. if (result.data.length) i = expectSpaceAndCharIndex(i, ',') + 1;
  208. const item = parseItem(i);
  209. result.data.push(item);
  210. i = item.end;
  211. }
  212.  
  213. result.end = i + 1;
  214. return result;
  215. };
  216.  
  217. const parseObject = start => {
  218. const result = {
  219. type: 'object',
  220. data: [],
  221. start
  222. };
  223. let i = start + 1;
  224.  
  225. while (true) {
  226. i = expectIndex(skipWhitespace(i));
  227. if (raw[i] === '}') break;
  228. if (result.data.length) i = expectSpaceAndCharIndex(i, ',') + 1;
  229. i = expectSpaceAndCharIndex(i, '"');
  230. const key = parseString(i);
  231. i = expectSpaceAndCharIndex(key.end, ':') + 1;
  232. const value = parseItem(i);
  233. result.data.push({
  234. key,
  235. value
  236. });
  237. i = value.end;
  238. }
  239.  
  240. result.end = i + 1;
  241. return result;
  242. };
  243.  
  244. parseItem = start => {
  245. const i = expectIndex(skipWhitespace(start));
  246. const ch = raw[i];
  247. if (ch === '"') return parseString(i);
  248. if (ch === '[') return parseArray(i);
  249. if (ch === '{') return parseObject(i);
  250. if ('-0123456789'.includes(ch)) return parseNumber(i);
  251. return parseKeyword(i);
  252. };
  253.  
  254. const result = parseItem(0);
  255. const end = skipWhitespace(result.end);
  256. if (end < raw.length) expectChar(end, []);
  257. return result;
  258. }
  259.  
  260. function loadJSON() {
  261. const raw = document.body.innerText;
  262.  
  263. try {
  264. // JSON
  265. const content = tokenize(raw);
  266. return {
  267. raw,
  268. content
  269. };
  270. } catch (e) {
  271. // not JSON
  272. console.error('Not JSON', e);
  273. }
  274.  
  275. try {
  276. // JSONP
  277. const parts = raw.match(/^(.*?\w\s*\()(.+)(\)[;\s]*)$/);
  278. const content = tokenize(parts[2]);
  279. return {
  280. raw,
  281. content,
  282. prefix: /*#__PURE__*/React.createElement("span", {
  283. className: "subtle"
  284. }, parts[1].trim()),
  285. suffix: /*#__PURE__*/React.createElement("span", {
  286. className: "subtle"
  287. }, parts[3].trim())
  288. };
  289. } catch (e) {
  290. // not JSONP
  291. console.error('Not JSONP', e);
  292. }
  293. }
  294.  
  295. function formatJSON() {
  296. if (formatter.formatted) return;
  297. formatter.formatted = true;
  298. formatter.data = loadJSON();
  299. if (!formatter.data) return;
  300. formatter.style = GM_addStyle(css);
  301. formatter.root = /*#__PURE__*/React.createElement("div", {
  302. id: "json-formatter"
  303. });
  304. document.body.innerHTML = '';
  305. document.body.append(formatter.root);
  306. initTips();
  307. initMenu();
  308. bindEvents();
  309. generateNodes(formatter.data, formatter.root);
  310. }
  311.  
  312. function generateNodes(data, container) {
  313. const rootSpan = /*#__PURE__*/React.createElement("span", null);
  314. const root = /*#__PURE__*/React.createElement("div", null, rootSpan);
  315. const pre = /*#__PURE__*/React.createElement("pre", null, root);
  316. formatter.pre = pre;
  317. const queue = [{
  318. el: rootSpan,
  319. elBlock: root,
  320. ...data
  321. }];
  322.  
  323. while (queue.length) {
  324. const item = queue.shift();
  325. const {
  326. el,
  327. content,
  328. prefix,
  329. suffix
  330. } = item;
  331. if (prefix) el.append(prefix);
  332.  
  333. if (content.type === 'array') {
  334. queue.push(...generateArray(item));
  335. } else if (content.type === 'object') {
  336. queue.push(...generateObject(item));
  337. } else {
  338. const {
  339. type,
  340. color
  341. } = content;
  342. if (type === 'string') el.append(createQuote());
  343. if (color) el.append( /*#__PURE__*/React.createElement("span", {
  344. className: "color",
  345. style: `background-color: ${content.data}`
  346. }));
  347. el.append( /*#__PURE__*/React.createElement("span", {
  348. className: `${type} item`,
  349. "data-type": type,
  350. "data-value": toString(content)
  351. }, toString(content)));
  352. if (type === 'string') el.append(createQuote());
  353. }
  354.  
  355. if (suffix) el.append(suffix);
  356. }
  357.  
  358. container.append(pre);
  359. updateView();
  360. }
  361.  
  362. function toString(content) {
  363. return `${content.source}`;
  364. }
  365.  
  366. function setFolder(el, length) {
  367. if (length) {
  368. el.classList.add('complex');
  369. el.append( /*#__PURE__*/React.createElement("div", {
  370. className: "folder"
  371. }, '\u25b8'), /*#__PURE__*/React.createElement("span", {
  372. className: "summary"
  373. }, `// ${length} items`));
  374. }
  375. }
  376.  
  377. function generateArray({
  378. el,
  379. elBlock,
  380. content
  381. }) {
  382. const elContent = content.data.length && /*#__PURE__*/React.createElement("div", {
  383. className: "content"
  384. });
  385. setFolder(elBlock, content.data.length);
  386. el.append( /*#__PURE__*/React.createElement("span", {
  387. className: "bracket"
  388. }, "["), elContent || ' ', /*#__PURE__*/React.createElement("span", {
  389. className: "bracket"
  390. }, "]"));
  391. return content.data.map((item, i) => {
  392. const elValue = /*#__PURE__*/React.createElement("span", null);
  393. const elChild = /*#__PURE__*/React.createElement("div", null, elValue);
  394. elContent.append(elChild);
  395. if (i < content.data.length - 1) elChild.append(createComma());
  396. return {
  397. el: elValue,
  398. elBlock: elChild,
  399. content: item
  400. };
  401. });
  402. }
  403.  
  404. function generateObject({
  405. el,
  406. elBlock,
  407. content
  408. }) {
  409. const elContent = content.data.length && /*#__PURE__*/React.createElement("div", {
  410. className: "content"
  411. });
  412. setFolder(elBlock, content.data.length);
  413. el.append( /*#__PURE__*/React.createElement("span", {
  414. className: "bracket"
  415. }, '{'), elContent || ' ', /*#__PURE__*/React.createElement("span", {
  416. className: "bracket"
  417. }, '}'));
  418. return content.data.map(({
  419. key,
  420. value
  421. }, i) => {
  422. const elValue = /*#__PURE__*/React.createElement("span", null);
  423. const elChild = /*#__PURE__*/React.createElement("div", null, createQuote(), /*#__PURE__*/React.createElement("span", {
  424. className: "key item",
  425. "data-type": key.type
  426. }, key.data), createQuote(), ': ', elValue);
  427. if (i < content.data.length - 1) elChild.append(createComma());
  428. elContent.append(elChild);
  429. return {
  430. el: elValue,
  431. content: value,
  432. elBlock: elChild
  433. };
  434. });
  435. }
  436.  
  437. function updateView() {
  438. formatter.options.forEach(({
  439. key
  440. }) => {
  441. formatter.pre.classList[config[key] ? 'add' : 'remove'](key);
  442. });
  443. }
  444.  
  445. function removeEl(el) {
  446. el.remove();
  447. }
  448.  
  449. function initMenu() {
  450. const handleCopy = () => {
  451. GM_setClipboard(formatter.data.raw);
  452. };
  453.  
  454. const handleMenuClick = e => {
  455. const el = e.target;
  456. const {
  457. key
  458. } = el.dataset;
  459.  
  460. if (key) {
  461. config[key] = !config[key];
  462. GM_setValue('config', config);
  463. el.classList.toggle('active');
  464. updateView();
  465. }
  466. };
  467.  
  468. formatter.root.append( /*#__PURE__*/React.createElement("div", {
  469. className: "menu",
  470. onClick: handleMenuClick
  471. }, /*#__PURE__*/React.createElement("span", {
  472. onClick: handleCopy
  473. }, "Copy"), formatter.options.map(item => /*#__PURE__*/React.createElement("span", {
  474. className: `toggle${config[item.key] ? ' active' : ''}`,
  475. dangerouslySetInnerHTML: {
  476. __html: item.title
  477. },
  478. "data-key": item.key
  479. }))));
  480. }
  481.  
  482. function initTips() {
  483. const tips = /*#__PURE__*/React.createElement("div", {
  484. className: "tips",
  485. onClick: e => {
  486. e.stopPropagation();
  487. }
  488. });
  489.  
  490. const hide = () => removeEl(tips);
  491.  
  492. document.addEventListener('click', hide, false);
  493. formatter.tips = {
  494. node: tips,
  495. hide,
  496.  
  497. show(range) {
  498. const {
  499. scrollTop
  500. } = document.body;
  501. const rects = range.getClientRects();
  502. let rect;
  503.  
  504. if (rects[0].top < 100) {
  505. rect = rects[rects.length - 1];
  506. tips.style.top = `${rect.bottom + scrollTop + gap}px`;
  507. tips.style.bottom = '';
  508. } else {
  509. [rect] = rects;
  510. tips.style.top = '';
  511. tips.style.bottom = `${formatter.root.offsetHeight - rect.top - scrollTop + gap}px`;
  512. }
  513.  
  514. tips.style.left = `${rect.left}px`;
  515. const {
  516. type,
  517. value
  518. } = range.startContainer.dataset;
  519. tips.innerHTML = '';
  520. tips.append( /*#__PURE__*/React.createElement("span", {
  521. className: "tips-key"
  522. }, "type"), ': ', /*#__PURE__*/React.createElement("span", {
  523. className: "tips-val",
  524. dangerouslySetInnerHTML: {
  525. __html: type
  526. }
  527. }));
  528.  
  529. if (type === 'string' && /^(https?|ftps?):\/\/\S+/.test(value)) {
  530. tips.append( /*#__PURE__*/React.createElement("br", null), /*#__PURE__*/React.createElement("a", {
  531. className: "tips-link",
  532. href: value,
  533. target: "_blank",
  534. rel: "noopener noreferrer"
  535. }, "Open link"));
  536. }
  537.  
  538. formatter.root.append(tips);
  539. }
  540.  
  541. };
  542. }
  543.  
  544. function selectNode(node) {
  545. const selection = window.getSelection();
  546. selection.removeAllRanges();
  547. const range = document.createRange();
  548. range.setStartBefore(node.firstChild);
  549. range.setEndAfter(node.firstChild);
  550. selection.addRange(range);
  551. return range;
  552. }
  553.  
  554. function bindEvents() {
  555. formatter.root.addEventListener('click', e => {
  556. e.stopPropagation();
  557. const {
  558. target
  559. } = e;
  560.  
  561. if (target.classList.contains('item')) {
  562. formatter.tips.show(selectNode(target));
  563. } else {
  564. formatter.tips.hide();
  565. }
  566.  
  567. if (target.classList.contains('folder')) {
  568. target.parentNode.classList.toggle('collapse');
  569. }
  570. }, false);
  571. }
  572.  
  573. }());