JSON Prettier

Format JSON data in a beautiful way.

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