GitHub Code Show Whitespace

A userscript that shows whitespace (space, tabs and carriage returns) in code blocks

目前為 2021-01-31 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name GitHub Code Show Whitespace
  3. // @version 1.2.11
  4. // @description A userscript that shows whitespace (space, tabs and carriage returns) in code blocks
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @include https://gist.github.com/*
  10. // @run-at document-idle
  11. // @grant GM_registerMenuCommand
  12. // @grant GM.registerMenuCommand
  13. // @grant GM.addStyle
  14. // @grant GM_addStyle
  15. // @grant GM.getValue
  16. // @grant GM_getValue
  17. // @grant GM.setValue
  18. // @grant GM_setValue
  19. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
  20. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=882023
  21. // @icon https://github.githubassets.com/pinned-octocat.svg
  22. // ==/UserScript==
  23. /* global GM */
  24. (async () => {
  25. "use strict";
  26.  
  27. let showWhiteSpace = await GM.getValue("show-whitespace", "false");
  28.  
  29. // include em-space & en-space?
  30. const whitespace = {
  31. // Applies \xb7 (·) to every space
  32. "%20" : "<span class='pl-space ghcw-whitespace'> </span>",
  33. // Applies \xb7 (·) to every non-breaking space (alternative: \u2423 (␣))
  34. "%A0" : "<span class='pl-nbsp ghcw-whitespace'>&nbsp;</span>",
  35. // Applies \xbb (») to every tab
  36. "%09" : "<span class='pl-tab ghcw-whitespace'>\x09</span>",
  37. // non-matching key; applied manually
  38. // Applies \u231d (⌝) to the end of every line
  39. // (alternatives: \u21b5 (↵) or \u2938 (⤸))
  40. "CRLF" : "<span class='pl-crlf ghcw-whitespace'></span>\n"
  41. };
  42. const span = document.createElement("span");
  43. // ignore +/- in diff code blocks
  44. const regexWS = /(\x20|&nbsp;|\x09)/g;
  45. const regexCR = /\r*\n$/;
  46. const regexExceptions = /(\.md)$/i;
  47.  
  48. const toggleButton = document.createElement("div");
  49. toggleButton.className = "ghcw-toggle btn btn-sm tooltipped tooltipped-s";
  50. toggleButton.setAttribute("aria-label", "Toggle Whitespace");
  51. toggleButton.innerHTML = "<span class='pl-tab'></span>";
  52.  
  53. GM.addStyle(`
  54. div.file-actions > div,
  55. .ghcw-active .ghcw-whitespace,
  56. .gist-content-wrapper .file-actions .btnGroup {
  57. position: relative;
  58. display: inline;
  59. }
  60. .gist-content-wrapper .ghcw-toggle {
  61. padding: 5px 10px; /* gist only */
  62. }
  63. .ghcw-toggle + .BtnGroup {
  64. margin-left: 4px;
  65. }
  66. .ghcw-active .ghcw-whitespace:before {
  67. position: absolute;
  68. opacity: .5;
  69. user-select: none;
  70. font-weight: bold;
  71. color: #777 !important;
  72. top: -.25em;
  73. left: 0;
  74. }
  75. .ghcw-toggle .pl-tab {
  76. pointer-events: none;
  77. }
  78. .ghcw-active .pl-space:before {
  79. content: "\\b7";
  80. }
  81. .ghcw-active .pl-nbsp:before {
  82. content: "\\b7";
  83. }
  84. .ghcw-active .pl-tab:before,
  85. .ghcw-toggle .pl-tab:before {
  86. content: "\\bb";
  87. }
  88. .ghcw-active .pl-crlf:before {
  89. content: "\\231d";
  90. top: .1em;
  91. }
  92. /* weird tweak for diff markdown files - see #27 */
  93. .ghcw-adjust .ghcw-active .ghcw-whitespace:before {
  94. left: .6em;
  95. }
  96. /* hide extra leading space added to diffs - see #27 */
  97. .diff-table tr.blob-expanded td > span:first-child .pl-space:first-child {
  98. visibility: hidden;
  99. }
  100. .blob-code-inner > br {
  101. display: none !important;
  102. }
  103. `);
  104.  
  105. function addFileActions() {
  106. // file-actions removed from repo file view;
  107. // still used in gists & file diffs
  108. if (!$(".file-actions")) {
  109. const rawBtn = $("#raw-url");
  110. if (rawBtn) {
  111. const group = rawBtn.closest(".BtnGroup");
  112. const fileActionWrap = group && group.parentNode;
  113. if (fileActionWrap) {
  114. fileActionWrap.classList.add("file-actions");
  115. }
  116. }
  117. }
  118. }
  119.  
  120. function addToggle() {
  121. addFileActions();
  122. $$(".file-actions").forEach(el => {
  123. // Don't add a toggle for new gists (editor showing)
  124. if (!$(".ghcw-toggle", el) && !$("#indent-mode", el)) {
  125. const dropdown = $(".dropdown", el);
  126. // (* + sibling) Indicates where the whitespace toggle is added
  127. // PR Layout: div.file-actions > div.flex-items-stretch > (details.dropdown + *)
  128. // Repo file: div.file-actions > (* + div.BtnGroup) > a#raw-url
  129. // Gist: div.file-actions > (* + a)
  130. if (dropdown) {
  131. el = dropdown.parentNode; // Fixes #91
  132. }
  133. el.insertBefore(toggleButton.cloneNode(true), el.childNodes[0]);
  134. }
  135. if (showWhiteSpace === "true") {
  136. // Let the page render a bit before going nuts
  137. setTimeout(show(el, true), 200);
  138. }
  139. });
  140. }
  141.  
  142. function getNodes(line) {
  143. const nodeIterator = document.createNodeIterator(
  144. line,
  145. NodeFilter.SHOW_TEXT,
  146. () => NodeFilter.FILTER_ACCEPT
  147. );
  148. let currentNode,
  149. nodes = [];
  150. while ((currentNode = nodeIterator.nextNode())) {
  151. nodes.push(currentNode);
  152. }
  153. return nodes;
  154. }
  155.  
  156. function escapeHTML(html) {
  157. return html.replace(/[<>"'&]/g, m => ({
  158. "<": "&lt;",
  159. ">": "&gt;",
  160. "&": "&amp;",
  161. "'": "&#39;",
  162. "\"": "&quot;"
  163. }[m]));
  164. }
  165.  
  166. function replaceWhitespace(html) {
  167. return escapeHTML(html).replace(regexWS, s => {
  168. let idx = 0,
  169. ln = s.length,
  170. result = "";
  171. for (idx = 0; idx < ln; idx++) {
  172. result += whitespace[encodeURI(s[idx])] || s[idx] || "";
  173. }
  174. return result;
  175. });
  176. }
  177.  
  178. function replaceTextNode(nodes) {
  179. let node, indx, el,
  180. ln = nodes.length;
  181. for (indx = 0; indx < ln; indx++) {
  182. node = nodes[indx];
  183. if (
  184. node &&
  185. node.nodeType === 3 &&
  186. node.textContent &&
  187. node.textContent.search(regexWS) > -1
  188. ) {
  189. el = span.cloneNode();
  190. el.innerHTML = replaceWhitespace(node.textContent.replace(regexCR, ""));
  191. node.parentNode.insertBefore(el, node);
  192. node.parentNode.removeChild(node);
  193. }
  194. }
  195. }
  196.  
  197. function* modifyLine(lines) {
  198. while (lines.length) {
  199. const line = lines.shift();
  200. // first node is a syntax string and may have leading whitespace
  201. replaceTextNode(getNodes(line));
  202. // remove end CRLF if it exists; then add a line ending
  203. const html = line.innerHTML;
  204. const update = html.replace(regexCR, "") + whitespace.CRLF;
  205. if (update !== html) {
  206. line.innerHTML = update;
  207. }
  208. }
  209. yield lines;
  210. }
  211.  
  212. function addWhitespace(block) {
  213. if (block && !block.classList.contains("ghcw-processed")) {
  214. block.classList.add("ghcw-processed");
  215. let status;
  216.  
  217. // class name of each code row
  218. const lines = $$(".blob-code-inner:not(.blob-code-hunk)", block);
  219. const iter = modifyLine(lines);
  220.  
  221. // loop with delay to allow user interaction
  222. const loop = () => {
  223. for (let i = 0; i < 40; i++) {
  224. status = iter.next();
  225. }
  226. if (!status.done) {
  227. requestAnimationFrame(loop);
  228. }
  229. };
  230. loop();
  231. }
  232. }
  233.  
  234. function detectDiff(wrap) {
  235. const header = $(".file-header", wrap);
  236. if ($(".diff-table", wrap) && header) {
  237. const file = header.getAttribute("data-path");
  238. if (
  239. // File Exceptions that need tweaking (e.g. ".md")
  240. regexExceptions.test(file) ||
  241. // files with no extension (e.g. LICENSE)
  242. file.indexOf(".") === -1
  243. ) {
  244. // This class is added to adjust the position of the whitespace
  245. // markers for specific files; See issue #27
  246. wrap.classList.add("ghcw-adjust");
  247. }
  248. }
  249. }
  250.  
  251. function showAll() {
  252. $$(".blob-wrapper .highlight, .file .highlight").forEach(target => {
  253. show(target, true);
  254. });
  255. }
  256.  
  257. function show(target, state) {
  258. const wrap = target.closest(".file, .Box");
  259. const block = $(".highlight", wrap);
  260. if (block) {
  261. wrap.querySelector(".ghcw-toggle").classList.toggle("selected", state);
  262. block.classList.toggle("ghcw-active", state);
  263. detectDiff(wrap);
  264. addWhitespace(block);
  265. }
  266. }
  267.  
  268. function $(selector, el) {
  269. return (el || document).querySelector(selector);
  270. }
  271.  
  272. function $$(selector, el) {
  273. return [...(el || document).querySelectorAll(selector)];
  274. }
  275.  
  276. // bind whitespace toggle button
  277. document.addEventListener("click", event => {
  278. const target = event.target;
  279. if (
  280. target.nodeName === "DIV" &&
  281. target.classList.contains("ghcw-toggle")
  282. ) {
  283. show(target);
  284. }
  285. });
  286.  
  287. GM.registerMenuCommand("Set GitHub Code White Space", async () => {
  288. let val = prompt("Always show on page load (true/false)?", showWhiteSpace);
  289. if (val !== null) {
  290. val = (val || "").toLowerCase();
  291. await GM.setValue("show-whitespace", val);
  292. showWhiteSpace = val;
  293. showAll();
  294. }
  295. });
  296.  
  297. document.addEventListener("ghmo:container", addToggle);
  298. document.addEventListener("ghmo:diff", addToggle);
  299. // toggle added to diff & file view
  300. addToggle();
  301.  
  302. })();