JSON formatter

Format JSON data in a beautiful way.

当前为 2020-02-29 提交的版本,查看 最新版本

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