GitHub Code Show Whitespace

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

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