GitHub Code Show Whitespace

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

Per 24-10-2022. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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